用户判断一个 Agent 靠不靠谱,用的不是准确率,是它有没有在动。同一个工具跑 600 毫秒,你可以让用户盯着空白等 600 毫秒,也可以在第 100 毫秒就告诉他扫到第几片了。后者不会让任务更快,但会让它看起来更可信。
工具固定 600 毫秒、分 6 段推进,对比“只在结束时发一次结果”与“每段用 onUpdate 推送”两种模式下的首次可见时间。
事件流本身就是给界面的协议
循环发出来的九种事件,本质上是一份界面渲染协议:agent_start 代表开始思考,turn_start 代表新的一轮,message_update 携带文字增量,tool_execution_start 代表开始调用工具,tool_execution_update 携带进度,tool_execution_end 代表工具完成。
不需要额外设计一套状态管理,也不需要在后端和前端之间同步一个“当前状态”字段。把事件按顺序重放,界面自然就是对的。这是事件驱动的最大好处:不存在状态漂移,只有事件缺失。
这也是一个排查思路。界面上显示和实际不符,一定是某类事件没被处理,而不是“状态不对”。事件是唯一真相源,状态只是它的投影。
把进度推出去
execute: async (id, params, signal, onUpdate) => {
for (let i = 1; i <= 6; i++) {
await scanShard(i, signal)
onUpdate?.({
content: [{ type: 'text', text: `已扫描 ${i}/6 个分片` }],
details: { done: i, total: 6 },
})
}
return { content: [{ type: 'text', text: '扫描完成,3 条异常' }], details: { done: 6, total: 6 } }
}
背景:onUpdate 的第三个参数是 AbortSignal。长任务里必须把它往下传——用户点了取消,扫描循环要真的停下,而不是等它自己跑完。这是中断语义能不能落地的地方,第 8 篇会展开。
508 毫秒的差,感知上是两个产品
我测了两种模式。不报进度时,第一次有东西可显示是在 611 毫秒——也就是工具结束那一刻。用 onUpdate 每 100 毫秒推一次,第一次可见是 103 毫秒。
508 毫秒的差距在人类感知里是明显的:低于 200 毫秒感觉是“立刻”,超过 500 毫秒开始怀疑“是不是卡了”,超过 1 秒很多人会刷新页面。
更重要的不是感知,是可中断性。界面上一片空白的时候,用户不知道该不该等;一旦看到“正在扫描 3/6”,他就能判断这次还要多久,要不要停。

首次可见时间:不报进度要等 611 毫秒,用 onUpdate 只要 103 毫秒。总耗时几乎一样,感知完全不同。
两种模式的实测对照
| 指标 | 仅结束事件 | onUpdate 流式 | 差值 |
|---|---|---|---|
| 首次可见(毫秒) | 611 | 103 | -508 |
| 总耗时(毫秒) | 612 | 610 | -2 |
| 中间事件数 | 0 | 6 | +6 |
| 用户可判断剩余时间 | 否 | 是 | — |
别把进度做成噪音
进度推送不是越多越好。每 10 毫秒推一次,界面会被高频更新拖垮,用户也读不出信息。合理的粒度是「每完成一个有意义的单位推一次」——一个分片、一批文件、一条记录。
还有一点:进度内容要能回答「还要多久」。只推「正在处理」没有信息量,推「3/6,预计还要 300 毫秒」才有。所以 details 里带上 done 和 total,让界面自己算百分比。
最后是节流。如果工具的天然粒度太细(比如逐行处理十万行日志),在工具内部累积到一定量再推,而不是每行推一次。
放到服务端要考虑的三件事
本地跑和放到服务端是两回事。第一件是传输:这些事件要转发给浏览器,SSE 是最省的方案,因为它是单向推送,不需要 WebSocket 的双向握手,也能穿过大多数代理。转发的时候只挑界面需要的字段,不要整个消息对象序列化——一条 tool result 里可能塞了几百 KB 的日志。
第二件是顺序。事件的价值在于顺序,乱序重放会得到错误的界面。用一条自增序号或单调时间戳标注每个事件,前端检测到跳号就重新拉一次状态快照,而不是硬着头皮往下画。
第三件是断线。长任务里网络中断几乎必然发生,用户刷新页面之后不该看到一个空界面。要把事件落盘(哪怕只是追加到一个本地 JSONL),重连时按 last-event-id 补发。这件事在脚本编排时代不需要考虑,因为脚本是同步阻塞的;一旦换成循环,任务可能跑十几分钟,补发就成了必需品。
顺带一个反直觉的结论:事件持久化的成本比日志持久化低。因为事件是结构化的,落盘体积小;而日志是自由文本,事后要靠正则去猜当时发生了什么。把事件存好,等于把现场存好了。
事件流打通之后,下一层问题就来了:上下文会越积越长。第 6 篇讲消息的两套模型,以及为什么压缩必须在 Agent 层做、不能写进提示词。
引用与来源
- pi-agent-core 事件类型表 — 九种事件与 tool_execution_update 语义
- AgentToolUpdateCallback 类型定义 — 版本 1.4.0











评论前必须登录!
立即登录 注册