✦ 大道至简 · 时光是画在卷上的河流 · 行到水穷处,坐看云起时

agent2026.08.28 · 9 分钟阅读

agent流式输出拆解

让 Agent 的工具调用像打字机一样流式输出:一个 mini-cursor 的实现拆解

L

Leo

2026.08.28 · 更新于 2026.09.22

5 次浏览
agent流式输出拆解

让 Agent 的工具调用像打字机一样流式输出:一个 mini-cursor 的实现拆解

让工具调用的 JSON 参数像打字机一样输出:一个"流式 + 工具调用"的增量展示方案。

引子

想象你在终端里跑一个 AI 编程助手,它正在帮你写一个 React 项目。你希望像 ChatGPT 那样,看到文件内容一行一行地蹦出来,而不是干等几十秒后一次性弹出整个文件。

"流式输出?"你说,"开启 streaming 不就行了?"

对纯文本,确实如此。但当 AI 输出的不是文字、而是工具调用的 JSON 参数时,事情就没那么简单了。这篇文章从一个真实代码片段出发,拆解一个关键问题:

如何把流式传输中、被任意切碎的 JSON 工具参数,像打字机一样增量打印出来?

一、先看:流式文本有多简单

ChatGPT 的流式,本质是:模型逐 token 生成,服务端通过 SSE 长连接分块推送,客户端每收到一块就渲染。LangChain 里拿到这块只需要一个 stream():

for await (const chunk of await model.stream(messages)) {
  process.stdout.write(chunk.content);
}

注意:到这里,"流式"已经达成了。 难点根本不在流式传输本身,而在下面的场景切换。

二、当输出从"文字"变成"工具调用"

Agent 要执行工具时,模型返回的不再是一段可直接读的文字,而是一个工具调用的描述,比如:

{"filePath": "src/App.tsx", "content": "import React from 'react';..."}

这个 JSON 同样是模型"逐 token 生成"的,所以流式传输时,它会被任意切碎。真实情况是这样的:

chunk 1 → args: "{\"filePath\": \"src/App.tsx\", \"conte"
chunk 2 → args: "nt\": \"import React fro"
chunk 3 → args: "m 'react';\n\nfunction App"
chunk 4 → args: "() { ... }\n\"}"

如果你像处理纯文本一样直接打印 chunk,屏幕会变成:

{"filePath": "src/App.tsx", "conte  nt": "import React fro  m 'react';\n\nfunction App  () { ... }\n"}

三处致命伤:

  1. 碎片乱码 —— conte 和 nt" 被拆成两个 chunk,直接打印根本读不通。
  2. 转义未还原 —— 流里看到的是 \n(反斜杠 + n 两个字符),不是真正的换行;" 是 \"。必须整体 JSON.parse 一次,才能还原成真实内容。
  3. 不知道何时完整 —— 要真正执行 write_file,必须拿到完整参数;而流式下任何时刻你都不知道下一个 chunk 是不是结尾。

于是问题从"如何流式传输"变成了:

如何从一块块碎片里,增量还原出结构化的工具参数,并且实时展示。

三、解法:累积 → 重解析 → 增量打印

这套方案的核心思路,是把"解析"从一次性动作,变成每来一个 chunk 就试一次。

第一步:累积(concat)

fullAIMessage = fullAIMessage ? fullAIMessage.concat(chunk) : chunk;

每个 chunk 都是一个 AIMessageChunk,concat 把它们拼接起来。关键是 fullAIMessage 始终代表**"到目前为止的完整状态"**,而不只是最后一块。

为什么必须累积?因为 JSON 的分割点不可预测——字符串可能在中途断开。只有拿着完整累积体,才有可能解析出正确结果。

第二步:反复尝试解析(JsonOutputToolsParser)

const toolParser = new JsonOutputToolsParser();

try {
    parsedTools = await toolParser.parseResult([{ message: fullAIMessage }]);
} catch (e) {
    // 解析失败说明 JSON 还不完整,忽略错误继续累积
}

LangChain 提供的 JsonOutputToolsParser 会收集消息里累积的 tool_call_chunks,把 args 碎片拼起来,尝试 JSON.parse:

  • 失败 → 说明 JSON 还缺一半,静默忽略,等下一个 chunk 再试;
  • 成功 → 返回完整的工具调用对象(含 filePath、content)。

因为每来一个 chunk 都重新解析一次,整个循环其实在反复"赌":当前拼起来的 JSON 够不够完整。一旦最后一个 } 到达,解析立刻成功。

第三步:增量打印(printedLengths + slice)

这一步是整个 mini-cursor 的灵魂:

const previousLength = printedLengths.get(toolCallId);
const newContent = currentContent.slice(previousLength);
process.stdout.write(newContent);
printedLengths.set(toolCallId, currentContent.length);

解析成功后,toolCall.args.content 是完整的文件内容(已经还原转义)。如果整段打印,那就不是流式了,而是"卡顿后一次性输出"。

所以用一个 Map 记录"这个工具调用已经打印了多少字符":

  1. 拿到当前完整 content;
  2. 和已打印长度比较;
  3. 只 slice 出新增的片段输出;
  4. 更新已打印长度。

效果就是:AI 每吐出一点 content,屏幕上就多打一点——文件内容被"打字机"打出来了。

Map 的 key 用 toolCall.id || toolCall.args.filePath,还有一层妙处:多个并行工具调用各记各的进度,互不干扰。这就是"mini-cursor(迷你光标)"的含义——每个文件都有自己的一根光标,各自向前推进。

一个小细节:为什么用 process.stdout.write

console.log 每次会自动补一个换行,会打断连续输出的流式观感;process.stdout.write 原样写出、不带换行,让内容能无缝追加。流式展示的基础设施,就靠这一个 API 换出来的。

四、完整流程

模型逐 token 生成工具调用 JSON
        ↓
stream() 分块推送 chunk(内容被任意切分)
        ↓
concat 累积 → fullAIMessage(始终最完整)
        ↓
JsonOutputToolsParser 尝试解析
  ├─ 失败 → 忽略,等下一个 chunk
  └─ 成功 → 拿到完整 content
                ↓
        printedLengths 查"已打印长度"
                ↓
        slice(已打印长度) → stdout.write 只打印新增段
        ↓
循环结束 → fullAIMessage 已完整 → 存回 history → 执行工具

五、总结:这套组合拳的要点

  1. 流式本身很简单,难的是"流式 + 结构化数据"。纯文本两行搞定;工具参数是 JSON,必须等完整才能解读、才能还原转义。
  2. 累积 + 重解析,是处理"任意切分 JSON"的通用套路。你不需要自己拼 JSON 碎片——LangChain 的 JsonOutputToolsParser 就是干这个的。
  3. 增量打印的核心是"长度去重"。记住已输出的长度,每次只输出增长的那一段,打字机效果就出来了。

下次写 Agent 工具调用的展示层时,记住这套 "累积 → 重解析 → 增量打印" 组合拳,就能复现 ChatGPT 级别的顺滑体验。

标签 / TAGSagent
L

Leo

博主

独立开发者 / Blogger,原博客「大道至简」维护者。正在把 WordPress 上攒了几年的文章与拾语迁移到 Next.js。

读者留言

COMMENTS · 0

发表留言

评论经审核后展示 · 请友善发言0/100