记一次把两个 Agent 页面的流式展示统一了,顺带挖出一个隐形 bug
起因:就想着把两个页面对齐一下
站里有两个用 LangGraph ReAct Agent 驱动的对话页面:
/chat知识问答机器人,RAG 检索作为工具按需调用,带会话持久化/create创作助手,挂在草稿编辑器右边的 Drawer 里,能改标题、写文案、回填草稿表单
本来这俩各写各的,前端各一套渲染、后端各一套 SSE 协议。前阵子我让一个模型帮忙把两个页面的展示方式封装一下、对齐成一套,结果它改了一半改不下去了,甩给我一个半成品。
我接手一看,两个问题:
/chat页面的工具调用,死活不显示/create那边根本没按统一方式改,还是老样子
我以为是个小修小补,结果一路挖下去,挖出来一个隐形的 P0 bug,还有一个根因级别的架构问题。这篇就记一下这个过程。
问题一:工具调用去哪了
先看 /chat。现象很明确:发一条会触发工具的消息,思考过程有,最终答案有,唯独中间那步「调了什么工具、返回了什么」凭空消失。
我先去抓了一份真实的 SSE 流,长这样(截取关键部分):
event: think_start
event: think_chunk {"content":"...需要使用 search_posts_meta 工具..."}
event: think_end
event: tool_start {"tool":"search_posts_meta","args":{...},"step":1}
event: tool_end {"tool":"search_posts_meta","result":"找到 178 篇...","step":1}
event: think_start
event: think_chunk {"content":"...我已经检索到了最新的一篇文章..."}
event: think_end
event: content_chunk {"content":"就在今天..."}
event: done
后端明明发了 tool_start / tool_end。前端 hook 也明明收到了,存进了 msg.tools。组件也明明把 tools 传给了渲染组件。那为啥不显示?
翻渲染组件,找到了这么一段「精妙」的逻辑:
ts
// 当 reactTimeline 为空但有 tools 时,从 tools 构建 fallback timeline
const toolsTimeline = useMemo(() => {
if (orderedTimeline.length > 0 || tools.length === 0) return [];
// ...把 tools 拼成 timeline
}, [orderedTimeline.length, tools]);
const effectiveTimeline = orderedTimeline.length > 0 ? orderedTimeline : toolsTimeline;
意思就是:只有当思考(reactTimeline)完全为空时,才用 tools 兜底渲染;一旦有了思考,tools 直接被忽略。
可现实是,DeepSeek 这种带推理的模型,工具调用前后都有一大段思考。于是思考一来,orderedTimeline 不为空,工具调用的 fallback 就被整个跳过了——工具被思考「盖住」了。
这就是看不到工具调用的原因。不是数据没到,是渲染逻辑把它丢了。
翻 git 历史找答案
既然是「统一展示」搞出来的,那原版 /chat 是怎么渲染工具的?
git log 翻到引入 reactTimeline 的那次重构(c9ca615),看原版怎么处理 tool_start / tool_end,一下就看明白了。原版的关键设计是:
工具调用和思考,是合并在同一个
reactTimeline里的,按 SSE 的真实时间顺序穿插。
思考是一段 think,工具调用是一轮 loop(里面装着 action / observation),最终答案是正文。渲染出来就是一条完整的时间线:
[思考折叠块] → [检索轮次 #1(带工具调用和结果)] → [再思考] → [正文]
而那个半成品把工具单独塞进了一个 tools 数组,跟 reactTimeline 分了家,再加上上面那个「有思考就不看 tools」的 fallback,工具自然就没了。
根因不是后端,是前端的数据建模走偏了。
修法:把工具融回时间线
思路很简单——让前端 hook 回到原版的姿势:收到 tool_start / tool_end 时,不要单独存 tools,而是按时间顺序融进 reactTimeline 的 loop 条目。
ts
case "tool_start": {
const { tool, args, step, startedAt } = data;
// 先把上一个进行中的 think 标记完成
const reactTimeline = msg.reactTimeline.map((item) =>
item.type === "think" ? { ...item, isStreaming: false } : item,
);
// 找到或新建对应 step 的 loop,推入一个 action 步骤
// ...
reactTimeline.push({
type: "loop",
index: step,
steps: [{ type: "action", content: argsContent, toolName: tool, startedAt, isStreaming: true }],
});
return { ...msg, reactTimeline };
}
case "tool_end": {
const { result, step, endedAt, durationMs } = data;
// 把该 loop 里最后一个 action 步骤转成 observation
// ...
}
顺手还修了 think_chunk 里一处原地 mutate 的状态 bug——原来的代码直接 last.content += content,React 状态更新这么写是有引用问题的,改成返回新对象。
渲染组件那边,把那个吞掉工具的 fallback 删了,直接老老实实按 reactTimeline 渲染。工具调用回来了。
顺藤摸瓜:是不是该有个共享层
修到这我停下来想:为啥会出这种 bug?
因为两个 agent 各写各的,没有共享层。/chat 一套 SSE 事件名、一套字段;/create 又一套。前端为了兼容两套,就写了各种 case "token": case "content_chunk": 的分支、各种 fallback。fallback 一多,早晚出事。
我让 explore agent 把两条链路从前后端都摸了一遍,结论是:
两个 agent 的骨架其实已经 70-80% 一样了——都用
createReactAgent、都用streamEvents、都用同一套 SSE 编解码。差异集中在「锦上添花」层,不在「地基」层。
而且摸出来一个更吓人的东西。
真凶:一个隐形的 P0 bug
调研的时候,agent 甩给我一行:
后端 create-agent 发的事件叫
draft_patch,前端useAgentStream监听的是patch。事件名对不上,patch 永远收不到。
我当时第一反应是不信,亲自去对了一遍代码:
ts
// create-agent.ts —— 发送
const emitPatch = (patch: DraftPatch) => {
enqueue('draft_patch', patch); // ← 发的是 draft_patch
};
// useAgentStream.ts —— 接收
case "patch": { // ← 监听的是 patch
onPatch?.(data);
}
注释里还煞有介事写着「draft_patch → patch 事件映射」,实际上根本没映射。
这意味着什么?意味着 /create 助手那个「改标题、写文案、回填表单」的核心功能,从头到尾就没真正工作过。模型辛辛苦苦调了 emit_draft_patch 工具,patch 帧发出去了,前端压根不认这个事件名,表单纹丝不动。
一个事件名,一行的事,藏了不知道多久。
既然要修,不如把地基打好
问题出在「没有共享层」,那就别再头疼医头了。这次干脆把两个 agent 共有的流式逻辑抽出来,沉到一个共享模块里。
新建了 src/services/ai/agent-stream/:
chunk-parsers.ts—— 从 chunk 里提取 reasoning(思考)、content(正文)、observation(工具结果摘要),纯函数,不依赖 LangChain 类型sse-emitter.ts—— 一个pumpAgentEvents,吃 streamEvents 事件流,吐标准 SSE 帧,处理思考/正文/工具的切换,工具还带上了startedAt/endedAt/durationMs
ts
export async function pumpAgentEvents(
eventStream: AsyncIterable<StreamEvent>,
emitter: SseEmitter,
options: PumpAgentEventsOptions,
): Promise<void> {
let inThink = false;
// ...
for await (const event of eventStream) {
if (event.event === 'on_chat_model_stream') {
// 分离 reasoning(think 帧)和 content(content_chunk 帧)
}
if (event.event === 'on_tool_start') { /* ... */ }
if (event.event === 'on_tool_end') { /* ... */ }
}
}
然后两个 agent 都改成调它。chat-agent 从 337 行瘦到 90 行,create-agent 也清爽了。事件名在共享层里就统一约定好了,以后不会再出现 draft_patch 对不上 patch 这种事。
bug 修了,顺手还白捡两个能力
共享层一抽,/create 立刻免费获得了两个之前没有的东西:
一是思考展示。 原来 create-agent 压根没做 reasoning 提取,模型如果开了思考模式,内容直接被丢掉。现在走共享层的 extractReasoningContent,思考过程能正常展示了。
二是工具耗时。 原来工具事件不带时间字段,时间线上看不出这步花了多久。现在 tool_end 自带 durationMs,渲染的时候能显示「调用 → 结果(1.2s)」。
检索 #1
? 调用 search_posts_meta · {"sort_by":"date",...}
? 结果 找到 178 篇相关文章:1. 给小破站加了个... (1.2s)
前端也顺手对齐
既然在收拾,前端两个页面的体验差异也一起抹平了。把 /chat 里的两个小逻辑抽成通用 hook:
useExternalLinks—— MutationObserver 监听消息容器,Markdown 里的链接自动在新标签页打开。/create之前没这个,点个链接就在编辑器里跳走了,草稿可能丢useSmartAutoScroll—— 智能滚动,用户停在底部时才自动跟随,上翻看历史时不强制拉回来。/create之前是粗暴地每次都拉到底
两个页面都接上,行为一致了。
几点反思
折腾完这一圈,有几个感触:
「统一」不能只统一表面。 那个半成品统一了前端组件、统一了类型定义,但后端两个 agent 的 SSE 协议还是各写各的。表面看着齐了,底下事件名对不上,于是有了那个隐形 P0。真正的统一得沉到协议层。
隐形 bug 最怕的是「功能看着能用」。 draft_patch 那个,因为模型偶尔会用别的方式表述、或者用户自己手动改了表单,可能一直没人发现「回填其实坏了」。要不是这次抓 SSE 对代码,可能还藏着。
共享层这种事,早抽早享受。 两个 agent 重复的代码(truncate、chunk 解析、SSE 编码)本来就在那摆着,一直没抽是因为「能跑」。等到要改的时候,改一处忘另一处,bug 就来了。这次抽完,chat 和 create 行为天然一致,改一处两边都生效。
没动的也明确了:/create 还是内存级、不持久化会话,这是我故意的——它挂在一个草稿编辑场景下,不需要聊天历史。不是所有能力都要搬,得看场景。
改完 typecheck 和 lint 都干净了,浏览器里也实跑验过了:/chat 的工具调用正常显示在思考之间、/create 改标题真的回填进表单了(P0 确认修复)、用带思考的模型时 create 那边的思考块也出来了。三件事都对得上。
如果你也在搞多 Agent 的项目,我的建议就一句:先把流式协议这一层钉死、共享出去,再往上面叠业务。 地基稳了,上面怎么折腾都踏实。