agent流式输出拆解
让 Agent 的工具调用像打字机一样流式输出:一个 mini-cursor 的实现拆解
Leo
2026.08.28 · 更新于 2026.09.22
让 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"}
三处致命伤:
- 碎片乱码 ——
conte和nt"被拆成两个 chunk,直接打印根本读不通。 - 转义未还原 —— 流里看到的是
\n(反斜杠 + n 两个字符),不是真正的换行;"是\"。必须整体JSON.parse一次,才能还原成真实内容。 - 不知道何时完整 —— 要真正执行
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 记录"这个工具调用已经打印了多少字符":
- 拿到当前完整 content;
- 和已打印长度比较;
- 只
slice出新增的片段输出; - 更新已打印长度。
效果就是: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 → 执行工具
五、总结:这套组合拳的要点
- 流式本身很简单,难的是"流式 + 结构化数据"。纯文本两行搞定;工具参数是 JSON,必须等完整才能解读、才能还原转义。
- 累积 + 重解析,是处理"任意切分 JSON"的通用套路。你不需要自己拼 JSON 碎片——LangChain 的
JsonOutputToolsParser就是干这个的。 - 增量打印的核心是"长度去重"。记住已输出的长度,每次只输出增长的那一段,打字机效果就出来了。
下次写 Agent 工具调用的展示层时,记住这套 "累积 → 重解析 → 增量打印" 组合拳,就能复现 ChatGPT 级别的顺滑体验。
读者留言
COMMENTS · 0发表留言