跳到正文

Coding Agent 原理与实践

02(下):模型接入:流式响应与组装

更新于:

作者:折腾客

约 4,403 字201 行代码

文章目录

1. 为什么 Agent 的回答会逐渐出现在屏幕上

使用 Coding Agent 时,回答往往是一段一段显示出来的。这是因为模型服务在生成过程中陆续返回内容,Agent 收到文字片段后,就把它交给界面显示。因此,我们可以在整条回复生成完毕之前,先看到已经返回的部分。

这种返回方式叫作流式响应。上篇请求中的 stream: true,就是让模型服务以这种方式返回内容。这些片段属于同一次模型调用,接入层需要持续接收。

不过,模型的回复不只有显示给用户的文字,还可能包含工具请求。继续看“读取 README.md,告诉我项目怎样启动”这个任务:模型请求调用 read 时,工具参数也可能分段到达。接入层已经收到了工具名,文件路径却还没有收全,此时还不能开始读取文件。

这就有了两件需要同时处理的事:收到的文字可以先用于显示,收到的片段还要保存下来,组装成完整回复。 本例会在回复按协议结束后,把完整消息交给 Agent 循环,由循环决定是否执行工具。

主流模型 API 通常通过 SSE(Server-Sent Events,服务器发送事件) 返回流式响应。服务端把陆续生成的内容放进一个个事件中发送,模型接入层读取这些事件,再从中提取文字和工具参数,逐步组装成完整回复。第 2.1 节会展开事件的读取过程;想了解 SSE 的事件格式,也可以阅读 MDN 的《使用服务器发送事件》。

2. 对照源码,看回复怎样组装起来

下面仍以 Ink 的 openai-completions.ts 为例。上篇讲到 provider.stream 发出请求并取得 response。接下来,同一个函数会继续读取响应体,组装模型回复。

先看整体过程:接收片段 → 累积文字和参数 → 检查结束状态 → 产出完整回复。(可在第 3 节的交互演示中逐步观察这个过程。)

下面保留主要变量和判断,省略类型、思考内容、用量统计等细节,不能单独运行;工具片段的处理在 2.2 展开。

// 位于 openai-completions.ts 的 provider.stream(request) 内。
const blocks = []; // 最终回复的内容块。
let textBlock = null;
const toolByIndex = new Map(); // 工具序号 → 工具请求块。
const toolRawByIndex = new Map(); // 工具序号 → 尚未解析的参数字符串。
let stopReason = null;
let sawFinish = false;

// parseSse 从字节流拆出事件;abortSafe 区分中断与故障,见 4.2。
for await (const sse of abortSafe(parseSse(response.body), request.signal)) {
  if (request.signal?.aborted) return;
  const chunk = sse.data;
  const choice = chunk.choices?.[0];

  // ① 保存文本,同时通知显示层。
  const content = choice?.delta?.content;
  if (typeof content === "string" && content !== "") {
    if (textBlock === null) {
      textBlock = { type: "text", text: "" };
      blocks.push(textBlock);
    }
    textBlock.text += content;
    yield { type: "text_delta", text: content };
  }

  // ② 按工具序号保存请求与参数片段,完整分支见 2.2。
  for (const tc of choice?.delta?.tool_calls ?? []) {
    // 省略:维护 toolByIndex、toolRawByIndex,并产出工具片段事件。
  }

  // ③ 记下服务给出的结束原因,见 4.1、4.4。
  if (choice?.finish_reason !== undefined && choice.finish_reason !== null) {
    sawFinish = true;
    stopReason = mapFinishReason(choice.finish_reason);
  }
  // 省略用量统计;有些事件只有用量,没有 choices[0]。
}

// ④ 流读取结束后,把累积的参数字符串解析成对象,见 4.3。
for (const [index, raw] of toolRawByIndex) {
  const ours = toolByIndex.get(index);
  if (ours !== undefined) {
    ours.arguments = raw.trim() === "" ? {} : safeParseJson(raw);
  }
}

if (request.signal?.aborted) return; // 不把中断的回复交给循环。
if (!sawFinish) throw new Error("响应缺少结束原因,可能已被截断");
// 省略未知结束原因的映射兜底和空回复占位。
yield {
  type: "message_end",
  message: { role: "assistant", blocks, usage, stopReason },
  // usage 由省略的统计代码提供。
};

这里的 yield 是异步生成器逐次产出事件的写法。调用者用 for await 依次接收这些事件。它与普通函数只在最后 return 一个结果不同:可以先通知“收到一段文字”,最后再通知“完整消息已经组装好”。

2.1 从响应体中读出完整的 SSE 事件

前面提到,服务通过 SSE 发送事件。事件以文本表示,用空行分隔,data: 行携带数据;本例用 JSON 表示模型回复片段。

接入层从响应体读到的最初是字节,一次读取可能只有半条事件,也可能含有好几条事件。因此,需要先识别完整事件,再提取其中的数据做 JSON.parse。

核心代码通过 parseSse(response.body) 完成这一步。下面是 shared.ts 中的函数节选,body 就是传入的响应体;省略类型及释放 reader 的 try/finally 结构:

async function* parseSse(body) {
  const reader = body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    let sep = buffer.indexOf("\n\n");
    while (sep !== -1) {
      const frame = buffer.slice(0, sep);
      buffer = buffer.slice(sep + 2); // 留下还没组成完整事件的部分。
      const parsed = parseFrame(frame);
      if (parsed) yield parsed;
      sep = buffer.indexOf("\n\n");
    }
  }
}

decoder.decode 中的 stream: true 是解码器选项,让它保留尚未完整的字符字节,避免一个汉字跨两次读取时被拆坏。它与上篇请求模型流式返回的同名字段用途不同。buffer 则保留尚未组成完整事件的文本。

parseSse 拆出的 frame 还是一段事件文本。接下来,同一文件中的 parseFrame 提取事件名和数据,再把数据解析成对象。下面省略类型声明,保留处理逻辑:

function parseFrame(frame) {
  let event = "message"; // 未指定 event: 时,使用默认的 SSE 事件名。
  const dataLines = [];
  for (const line of frame.split("\n")) {
    if (line.startsWith("event:")) {
      event = line.slice(6).trim();
    } else if (line.startsWith("data:")) {
      dataLines.push(line.slice(5).trimStart()); // 收集每行 data: 后的内容。
    }
  }
  if (dataLines.length === 0) return undefined;
  try {
    // 多行数据用换行符拼接,再解析其中的 JSON。
    return { event, data: JSON.parse(dataLines.join("\n")) };
  } catch {
    return undefined; // 当前实现跳过不能解析为 JSON 的内容,见 4.1。
  }
}

返回的 data 就是核心代码中 sse.data 的来源:parseSse 用 yield 交出 { event, data },外层 provider.stream 再从 data 中提取文字和工具参数。返回 undefined 时,该事件会被跳过。

其中,[DONE] 是 Chat Completions 流式响应的结束标记,服务端以 data: [DONE] 的形式发送,表示本次响应已发送完毕;它不代表 Agent 已完成用户的任务。这是接口的约定,不是 SSE 协议本身规定的标记。它不是合法 JSON,因此也会被这段代码跳过;结束标记与格式损坏的处理区别,留到 4.1 展开。

这段源码按 \n\n 寻找分隔符,是项目当前的简化实现,不是一份完整的通用 SSE 解析器。例如接入采用 \r\n 换行的端点时,需要补充处理并验证。读源码时要分清通用机制和某份实现实际覆盖的范围。

用读取 README 的事件走一遍

假设服务返回一个包含 read 工具请求片段的 SSE 事件,但接入层分两次才读全。下面使用模拟数据,只保留与解析有关的字段;代码块中的 \n 表示实际的换行字符。

① 第一次读取:先存起来。 解码后得到:

data: {"choices":[{"delta":{"tool_calls":[{"index":0,

这段文本写入 buffer 后,还找不到 \n\n,所以 sep 是 -1。此时不会调用 parseFrame,也不会向外产出事件,继续等待下一次读取。

② 第二次读取:找到事件的结尾。 新收到的文本是:

"id":"t1","function":{"name":"read","arguments":"{\"path\":\"REA"}}]}}]}\n\n

把它追加到 buffer 后,就有了完整的 data: 行和末尾的空行。parseSse 找到 \n\n,把它之前的文本取作 frame,交给 parseFrame;本例没有剩余内容,buffer 随之变为空字符串。

③ 解析事件:得到可读取的对象。 parseFrame 去掉 data: 前缀,把后面的 JSON 文本放入 dataLines,再用 JSON.parse 解析。这个事件没有 event: 行,因此事件名使用默认值 message,返回结果如下:

{
  "event": "message",
  "data": {
    "choices": [
      {
        "delta": {
          "tool_calls": [
            {
              "index": 0,
              "id": "t1",
              "function": {
                "name": "read",
                "arguments": "{\"path\":\"REA"
              }
            }
          ]
        }
      }
    ]
  }
}

parseSse 随后通过 yield 交出这个对象。核心代码沿着 sse.data → choices[0] → delta.tool_calls,就能取到其中的工具请求片段。注意:完整的是这一个 SSE 事件,工具参数还只有 {"path":"REA,接下来仍要继续收集参数。

2.2 把参数片段组装成完整的工具请求

接着上面的解析结果,从 sse.data.choices[0].delta.tool_calls 取出其中一项:

{
  "index": 0,
  "id": "t1",
  "function": { "name": "read", "arguments": "{\"path\":\"REA" }
}

外层事件是合法 JSON,但里面的 arguments 只是字符串 {"path":"REA,还不是完整的参数对象。下一条事件可能继续传来 DME.md"}。

因此需要两次不同的解析:先解析事件外层 JSON,取出参数片段;等参数收齐,再解析拼接后的参数 JSON。

同一条模型回复还可能请求读取两个文件,各自的片段交错到达。源码用 index 区分本次回复中的不同工具请求,分别累积;id 则保留下来,之后用于关联工具结果。下面就是核心代码第 ② 步省略的分支:

// 位于 provider.stream 内,对应核心代码第 ② 步。
for (const tc of choice?.delta?.tool_calls ?? []) {
  let block = toolByIndex.get(tc.index);
  const name = tc.function?.name;
  if (block === undefined) {
    block = {
      type: "toolCall",
      id: String(tc.id ?? `call_${tc.index}`),
      name: String(name ?? ""),
      arguments: {}, // 先建内部块;此时尚无完整参数。
    };
    toolByIndex.set(tc.index, block);
    blocks.push(block);
    toolRawByIndex.set(tc.index, "");
    yield { type: "tool_call_start", id: block.id, name: block.name };
  } else if (name !== undefined && block.name === "") {
    block.name = String(name); // 兼容工具名稍后到达的情况。
  }

  const fragment = tc.function?.arguments ?? "";
  if (fragment !== "") {
    toolRawByIndex.set(tc.index, (toolRawByIndex.get(tc.index) ?? "") + fragment);
    yield { type: "tool_call_delta", id: block.id, jsonDelta: fragment };
  }
}

这段代码做三件事:

  1. 找到对应的工具请求。 按 tc.index 查找工具块;第一次收到时创建它,记录 ID 和名称,并准备一个空字符串累积参数。
  2. 累积参数。 把 tc.function.arguments 中的新片段,追加到这个请求已有的字符串后面。不同 index 的参数分别保存。
  3. 通知接收进度。 首次创建时产出 tool_call_start,收到参数片段时产出 tool_call_delta。这些事件用于通知调用者,还不会执行工具,下一节会解释它们的用途。

toolByIndex 保存的对象也被放进了 blocks。流结束后,核心代码第 ④ 步解析累积的参数字符串,写入这个对象的 arguments,最终消息里的工具块也就得到了解析结果。参数解析失败的情况见 4.3。

源码在缺少 ID 时使用 call_序号 兜底;这不等于所有服务都接受这种补法。正常协议数据应保留服务给出的调用 ID,兼容其他端点时还要检查它何时发送 ID。本文演示的首个工具片段带有 ID 和名称。

2.3 用内部事件传递接收进度和完整消息

前面的 yield 把接收进度和最终结果以事件的形式传给调用者。第 01 篇中的 streamAssistant 负责接收这些事件。下面节选自 src/core/loop.ts 的这个函数,省略参数准备和用量统计代码:

// 位于 src/core/loop.ts 的 streamAssistant 函数内。
// request、provider 和 onEvent 来自省略的参数准备代码。
for await (const event of provider.stream(request)) {
  if (request.signal?.aborted) return null;
  onEvent?.(event); // 文本与工具片段可以先交给交互层显示。
  if (event.type === "message_end") {
    return { ...event.message, model: request.model };
  }
}
if (request.signal?.aborted) return null;
throw new Error("Provider stream ended without a message_end event");

onEvent 把收到的事件转发给交互层,用于更新显示;遇到 message_end 时,streamAssistant 取出其中的完整消息,返回给 Agent 循环。

这里接收的是 Agent 内部统一的流式事件,本篇涉及以下四种:

事件作用
text_delta传递一段新增文字,可用于更新显示
tool_call_start通知开始接收一个工具请求,携带 ID 和名称;不表示开始执行工具
tool_call_delta传递该工具请求的一段参数字符串
message_end携带组装后的完整消息,供 Agent 循环继续处理

模型接入层把不同服务的响应转换成这些事件,Agent 循环和交互层因此可以按同一套方式处理接收进度。源码把这套事件定义为 LLMEvent,见 provider/types.ts。

这里要区分事件和上篇的消息:事件传递接收过程中的变化,消息保存对话内容。message_end 把两者接起来——它携带一条完整的 assistant 消息。Agent 循环取得这条消息后,再将它写入 history,筛选工具请求并执行。

这样,界面可以逐段显示,历史中又不必为每个文字片段增加一条消息。交互层怎样响应这些事件,留到第 06 章;终端中具体怎样显示,留到第 07 章。

3. 模拟运行:半截路径怎样变成完整参数

下面从已经拆出的 SSE 事件开始观察,省略网络字节拆分、用量和思考内容。每点一次“下一步”,接收一个预设事件,或执行一次收尾检查。代码区保留完整的演示核心流程,并高亮当前步骤涉及的代码;可以在区内滚动查看前后逻辑。

先走“读取 README”,留意两个时刻:参数字符串已经拼完整,以及接入层真正发出 message_end。这份实现会继续读取到流结束,再解析参数、检查结束原因,并产出完整消息。

从参数片段到完整回复

模拟数据 · 不连接模型、不执行工具。JSON 展示省略用量等无关字段。

步骤 1 / 6等待第一条事件

HTTP 请求已成功。从完整 SSE 事件开始演示,不模拟网络字节拆分。

核心代码高亮:第 9–11 行

滚动可查看全部代码;高亮随步骤移动。辅助函数见第 2 节。

// provider.stream 内的核心流程;省略思考内容与用量统计。const blocks = [];let textBlock = null;const toolByIndex = new Map();const toolRawByIndex = new Map();let stopReason = null;let sawFinish = false; for await (const sse of abortSafe(  parseSse(response.body), request.signal)) {  if (request.signal?.aborted) return;  const choice = sse.data.choices?.[0];   const content = choice?.delta?.content;  if (typeof content === "string" && content !== "") {    if (textBlock === null) {      textBlock = { type: "text", text: "" };      blocks.push(textBlock);    }    textBlock.text += content;    yield { type: "text_delta", text: content };  }   for (const tc of choice?.delta?.tool_calls ?? []) {    let block = toolByIndex.get(tc.index);    const name = tc.function?.name;    if (block === undefined) {      block = {        type: "toolCall",        id: String(tc.id ?? `call_${tc.index}`),        name: String(name ?? ""), arguments: {},      };      toolByIndex.set(tc.index, block);      blocks.push(block);      toolRawByIndex.set(tc.index, "");      yield { type: "tool_call_start", id: block.id, name: block.name };    } else if (name !== undefined && block.name === "") {      block.name = String(name);    }    const fragment = tc.function?.arguments ?? "";    if (fragment !== "") {      toolRawByIndex.set(        tc.index, (toolRawByIndex.get(tc.index) ?? "") + fragment      );      yield { type: "tool_call_delta", id: block.id, jsonDelta: fragment };    }  }   if (choice?.finish_reason != null) {    sawFinish = true;    stopReason = mapFinishReason(choice.finish_reason);  }} for (const [index, raw] of toolRawByIndex) {  const ours = toolByIndex.get(index);  if (ours !== undefined) {    ours.arguments = raw.trim() === "" ? {} : safeParseJson(raw);  }} if (request.signal?.aborted) return;if (!sawFinish) throw new Error("响应可能已被截断");yield {  type: "message_end",  message: { role: "assistant", blocks, stopReason },};
本次收到
尚未收到事件
接入层暂存
尚无文本或工具参数
交给 Agent 循环
尚未产出 message_end;不会把片段作为完整消息写入 history。

在“两次读取交错到达”的场景中,会看到两个请求分别累积参数。不能按工具名合并,因为它们都叫 read;也不能把所有片段拼进同一个字符串。

再看“缺少结束原因”和“用户中断”:界面可能已经显示了片段,但演示不会生成一条完整回复。所有内容均为模拟数据,不连接模型、不读取文件。

4. 异常处理:接收和组装回复时出了问题怎么办

4.1 响应体读完了,却没有收到结束原因

假设文件路径只收到一半,响应体就读完了。接入层怎样发现这次回复可能被截断?

Chat Completions 用 finish_reason 表示模型停止生成的原因。第二节的核心代码在接收过程中记录它,并在读取结束后检查是否收到过:

// 位于 provider.stream,节选记录与检查结束原因的代码。
// 接收过程中:记录服务返回的结束原因。
if (choice?.finish_reason !== undefined && choice.finish_reason !== null) {
  sawFinish = true;
  stopReason = mapFinishReason(choice.finish_reason);
}

// 响应体读取结束后,且用户没有取消:
if (!sawFinish) {
  throw new Error(
    "OpenAI stream ended without finish_reason — response may be truncated"
  );
}

如果代理提前结束了响应,客户端可能读不到后续内容,却没有遇到读取错误。这时,缺少 finish_reason 就是异常的线索:接入层抛出错误,不产生 message_end。即使已有的参数恰好能解析成 JSON,也不能跳过这个检查。

第 2.1 节的解析器会跳过 [DONE],因此当前实现是在响应体读取结束后检查 sawFinish。不过,解析器也会跳过其他无法解析为 JSON 的事件;即使收到了结束原因,前面仍可能丢失过内容。这个检查能发现缺少结束原因的情况,但发现不了所有格式错误。

4.2 接收过程中,用户取消了或读取失败了

回答显示到一半时,用户可能按下取消,网络读取也可能发生错误。这两种情况需要分别处理:

  • 用户取消: 停止接收,不产生 message_end,Agent 循环也随之停止。
  • 其他读取错误: 将错误向上传递,让调用方处理。

第二节的核心代码会在接收过程中、发出 message_end 之前检查 request.signal。取消操作还可能让正在等待的网络读取抛出异常,因此 abortSafe 也要检查同一个信号:

// 节选 shared.ts,省略泛型和参数类型。
async function* abortSafe(source, signal) {
  try {
    for await (const item of source) yield item;
  } catch (err) {
    if (signal?.aborted) return; // 已取消,结束事件流。
    throw err; // 其他读取错误继续向上传递。
  }
}

这样,取消操作就不会被当作普通的读取故障继续抛出。Agent 循环如何响应中断,已在第 01 篇介绍,这里只关注接入层怎样停止接收。

这份实现不会在接收片段的过程中自动重试;上篇的重试只发生在取得 HTTP 响应的阶段。重新请求可能生成不同的回复,不能直接接到已有片段后面。如果要支持中途重试,还需要处理已经累积的内容和界面上显示的进度。

4.3 参数片段拼接后,仍无法解析成 JSON

假设累积的参数是 {"path":"README.md",缺少最后一个右花括号。接入层已经拼接了收到的片段,但 JSON.parse 仍会失败。

第二节核心代码的第 ④ 步调用 safeParseJson,在解析失败时返回一个包含错误说明的对象:

// 节选 shared.ts,省略参数和返回值类型。
function safeParseJson(raw) {
  try {
    return JSON.parse(raw);
  } catch {
    return {
      _parseError: "tool arguments were not valid JSON",
      raw: raw.slice(0, 500),
    };
  }
}

这个对象会被放入工具请求的 arguments。如果也收到了结束原因,且用户没有取消,接入层仍会发出 message_end,把消息交给 Agent 循环。

这里的 _parseError 只是错误信息,不会自动阻止工具执行。本例中的 read 会因为缺少必填的 path 被参数校验拒绝,但其他工具未必会拒绝这个对象。更稳妥的处理是显式识别解析失败,阻止这个工具请求进入执行阶段,并将失败原因交回 Agent 循环。

即使 JSON 解析成功,参数也可能不符合工具要求。例如 {"path":123} 是合法 JSON,但路径应该是字符串。这一层工具参数校验会在第 03 章展开。

4.4 达到输出上限,回复仍可能不完整

还有一种情况:服务正常返回了结束原因,但模型的回答或工具参数只生成了一部分,因为这次生成达到了输出上限。此时,finish_reason 的值是 length。

接入层通过 mapFinishReason 把服务返回的值转换成内部的停止原因:

服务返回的 finish_reason内部 stopReason表示什么
tool_callstool_use这次模型回复提出了工具请求
stopend_turn这次模型生成正常停止
lengthmax_tokens这次生成达到了输出限制

这里的 max_tokens 是内部停止原因的取值,用来记录“因输出限制而停止”,不是设置输出上限的配置字段。

因此,收到 message_end 表示接入层结束了本次组装,并交出消息;内容是否被截断,还要看 stopReason,工具参数是否可用还要继续校验。它也不代表 Agent 已完成用户的任务。

当前 Ink 会在 Agent 循环中继续检查这个停止原因,分别处理包含工具调用和不包含工具调用的回复。

回复包含工具调用时,拒绝执行这一条回复中的全部工具调用。 即使某个调用的参数能解析成合法 JSON,也不能据此判断参数已经完整生成。下面节选 loop.ts 中的处理分支;执行到这里时,循环尚未因中断或达到轮数上限而停止:

// 文件:src/core/loop.ts
// 所在函数:runAgentLoop,已取得本次回复中的工具调用。
// 节选截断判断与结果写入;省略中断后的结果补齐与空结果检查。
const results: ToolResult[] = [];
if (assistant.stopReason === "max_tokens") {
  // 不执行工具,为每个调用生成一条错误结果。
  failToolCallsFromTruncatedMessage(toolCalls, results, onEvent);
} else {
  await executeToolBatch(
    toolCalls,
    toolMap,
    signal,
    onToolCall,
    onEvent,
    results,
    clock
  );
}
// ……省略中断后的结果补齐与空结果检查。
const toolResults: AgentMessage = { role: "toolResult", results };
history.push(toolResults);
onMessage?.(toolResults);

failToolCallsFromTruncatedMessage 为每个调用生成 isError: true 的结果,说明这次回复达到了输出上限,参数可能被截断,要求模型重新提出参数完整的调用。这些结果写入历史后,只要没有中断等停止条件,循环就会再次调用模型。后续生成的是新的回复,不会直接拼接到原来的参数字符串后面,也不能保证模型下一次一定能生成完整参数。

回复不包含工具调用时,标记回答被截断并提示用户。 如果也没有待处理的追加输入,循环会结束运行,并在返回值中设置 truncated: true。runner.ts 的 printRunStats 读取这个标记,提示回答达到了输出上限,用户可以再发一条消息继续。这个标记本身不会触发自动续写;此时返回的 stopReason: "completed" 表示循环已经结束,不能据此认定回答完整或任务完成。

5. 模型请求工具之后,轮到本地执行

至此,上下两篇连起了一次模型通信:把内部消息转换成请求,接收返回的片段,再整理成循环能处理的完整回复。显示可以随片段推进,工具执行则要等完整请求,并继续经过参数与权限检查。

下一章进入工具系统:拿到 read 和文件路径后,怎样找到对应实现、检查参数、执行操作,并把成功或失败转换成模型能理解的结果。

流式工具字段可对照 OpenAI 的函数调用文档;本文的组装、错误兜底和中断行为以 Ink 源码为例。