Meta 宣布在人工智能领域取得重大突破,推出名为“Omnilingual”的机器翻译技术,成功将支持范围扩大至 1,600 种语言。这一举措远超现有主流翻译工具,特别针对缺乏训练数据的“低资源语言”进行了重点攻克。通过利用先进的大模型架构和合成数据技术,Meta 旨在打破全球语言壁垒,消除信息不对称,为数十亿目前无法获取数字信息的用户建立连接,同时也为其构建元宇宙的终极愿景奠定了坚实的沟通基础。
原文链接:Hacker News
Meta 宣布在人工智能领域取得重大突破,推出名为“Omnilingual”的机器翻译技术,成功将支持范围扩大至 1,600 种语言。这一举措远超现有主流翻译工具,特别针对缺乏训练数据的“低资源语言”进行了重点攻克。通过利用先进的大模型架构和合成数据技术,Meta 旨在打破全球语言壁垒,消除信息不对称,为数十亿目前无法获取数字信息的用户建立连接,同时也为其构建元宇宙的终极愿景奠定了坚实的沟通基础。
原文链接:Hacker News
极简框架把复杂度从框架搬到了你身上。循环本身只有 1445 行,它不替你做日志、不替你做预算、不替你做回滚、不替你做评测。它只保证一件事:模型做了什么、失败了什么,都变成事件发出来。剩下的账,得自己付。
20 条失败用例覆盖四类:调用不存在的工具、参数不合 schema、工具抛异常、工具超时。每条独立跑一次完整循环,统计失败信号是否送达模型、循环是否崩溃。
20 条用例,四类失败各 5 条。结果很干净:20 条全部产生了明确的错误结果,带 isError 标记和可读原因;循环零崩溃,每次都能继续或正常收尾。
四类的处理路径不一样。工具不存在时,循环直接生成「Tool xxx not found」的结果,不让模型空等;参数不合 schema 时,校验在执行前失败,工具根本没被调用;工具抛异常时,异常被捕获成错误结果;超时同理。
这个一致性是设计出来的,不是碰巧。它意味着模型永远知道「刚才那步为什么没成」,而不是收到一个空结果或者一个被吞掉的异常。上一代系统最缺的就是这一层。
| 失败类型 | 用例数 | 信号送达 | 循环崩溃 | 错误信息平均长度 |
|---|---|---|---|---|
| 工具不存在 | 5 | 5 | 0 | 74 字符 |
| 参数不合 schema | 5 | 5 | 0 | 90 字符 |
| 工具抛异常 | 5 | 5 | 0 | 77 字符 |
| 工具超时 | 5 | 5 | 0 | 88 字符 |
四类失败各 5 条,全部把错误如实送给模型,循环没有一次崩溃。
注意:但「信号送达」不等于「问题解决」。框架保证模型看见错误,不保证它看见之后能改对。实测里能跑通是因为策略是脚本化的、确定会换策略;真实模型可能在同一处反复试,这时候需要的是预算闸门和人工接管,不是更多的重试。
循环没有内置预算。模型可以一直调工具,直到上下文爆掉或者你手动停。必须在钩子里计次、在上层计 token,两道闸门都要有。
计次容易,计 token 麻烦——用量在 assistant 消息的 usage 字段里,要自己累加。累加的地方应该是 afterToolCall 或每轮结束,别放在界面层,界面层可能在重连后丢掉。
这不是理论风险。我让一个脚本化模型在同一个错误上反复重试:权限被拒,它继续读同一个路径。没有闸门时,30 轮上限内它把 30 次调用全用完了;加一条「8 次预算 + 拦下后 terminate」的规则,第 9 轮就收尾。

同一个死循环任务:无预算闸门时 30 轮上限内把 30 次调用全部用尽,加 8 次预算后 9 轮收尾。差的不只是次数,还有这 22 轮里每一个 token。
更值得记的是那 30 轮不是终点,是我给实验设的上限。没有 terminate,模型会在这个错误上继续试下去,直到上下文撑爆或者你手动 kill。脚本编排时代这个由进程超时兜着,循环里没有进程生命周期,只能靠钩子。
所以预算闸门要做两件事:到点拦住,并且让这一轮直接结束。只拦不终止,模型下一轮还会再请求同一个工具,你只是把频率降低了。
pi 生态里有独立的 pi-telemetry 包,但那是可选项。真正必须做的是把事件落盘:每个事件带会话 id、轮次、时间戳、工具名、耗时、结果摘要。有了这份结构化记录,任何一次线上异常都能重放出来。
反例是只记日志。日志是自由文本,事后要靠正则去猜当时发生了什么;事件是结构化的,可以直接算工具成功率、平均轮次、按失败类型分组。
循环会改东西——改文件、写库、重启服务。要能回滚,前提是每个有副作用的工具都留下「改之前是什么」。这件事框架不管,工具的作者必须管。
务实做法是给工具加一个 replay 策略字段:声明为 safe 的工具可以重放,声明为 never 的工具出错后必须人工确认。循环本身支持这个语义,但具体哪些工具是 safe,只有你知道。
真实模型做回归测试几乎不可能:温度、采样、上游版本都会让结果漂移。可行的做法是分层——循环用脚本化模型做单测,锁死「钩子是否拦截」「队列是否消费」「错误是否送达」这类行为;模型效果用固定用例集做抽查,允许答案措辞不同但要求关键结论一致。
评测集不要超过 30 条。超过就没人维护了。每条用例写清三件事:输入、期望的关键结论、不可接受的结果。
把这一篇和前九篇压缩成一张表。每一条都对应一个具体的失败模式,不是泛泛而谈。
| 检查项 | 失败模式 | 对应实验 |
|---|---|---|
| 工具描述能区分用途 | 模型选错工具、反复试同一个 | 04 |
| 失败带 code 与原因 | 模型不知道发生了什么,原样重试 | 10 |
| 危险动作有钩子 | 提示词约束失效,危险命令被执行 | 07 |
| 有调用预算 | 同一错误上无限重试,烧成本 | 07 |
| 上下文有剪枝策略 | 20 轮后上下文线性膨胀 | 06 |
| 长任务有进度事件 | 用户以为卡死,提前退出 | 05 |
| 支持插话 | 方向错了只能等跑完或杀进程 | 08 |
| 事件落盘可重放 | 线上异常无法还原 | — |
| 有回归集 | 换模型或改提示词后无据可依 | 10 |
回头看这十篇,pi-agent-core 做的事情可以一句话概括:它只实现循环,把所有判断留给你和模型。工具只有几个,没有 MCP,没有子 Agent,没有沙箱,没有计划模式。
这套设计的代价很明确,就是这一篇列的四件事都要自己做。收益也很明确:每个决定都是你做的,所以每个失败你都能定位。不会出现「框架内部在某处重试了三次」这种查不下去的情况。
从 LangGraph 迁过来最大的变化不是代码少了,是心态变了。以前你在设计图的形状,试图把所有分支提前想到;现在你在设计工具和闸门,接受模型会走弯路。前者追求确定性,后者追求可恢复性——面对开放的任务,可恢复比确定更值钱。
十篇的实验代码都在一个仓库里,每个数字都能自己跑一遍。如果你的场景是流程固定、分支可穷举,状态机仍然是更好的选择;如果你的场景是排障、调研、修复这类「下一步取决于这一步看到了什么」的任务,循环的性价比会高得多。
前八篇讲的是循环怎么用,这一篇讲怎么把一个已经在跑的系统换过去。改造不需要推倒重来——把「固定调用」换成「给工具加判断」,把「退出码」换成「结构化结果」,把「暗依赖」换成「显式参数」,三步就能让同一批任务从 20% 通过率走到 100% 有结论。
沿用第 1 篇的 30 个任务集:6 类故障、两类编排器。这一篇逐条对照 7 项差异,并统计 24 个失败任务在改造后的去向。
典型的写法是:平台里写一行 subprocess.run,参数固定为 –log-dir 和 –output-json,脚本名固定,cwd 不传。这一行代码在 80% 的情况下工作良好,所以没人觉得有问题。
问题出在剩下 20%。脚本可能叫 analyze_log.py,可能放在 scripts/ 子目录,可能需要额外参数。每一种变化都要改一次平台代码、发一次版本、等一次审批。改到最后,平台里堆了十几个 if 分支,每个分支对应一个客户现场的目录结构。
改造方式是把调用权交给模型:给它 ls 看目录、read 看内容、bash 执行。模型先看有什么,再决定跑什么。平台代码里一个分支都不用留。
# 改造前:一条路走到黑
subprocess.run(['analyze.py', '--log-dir', d, '--output-json'], check=True)
# 改造后:模型自己决定
# 模型看到 ls 输出 -> 发现 scripts/ -> 再 ls -> 找到 analyze_log.py -> 拼命令执行
# 平台只提供三个原语:ls / read / bash
背景:有人会担心「让模型拼命令不安全」。这个担心是对的,但解法不是把命令写死,是在 bash 工具前面加钩子——路径白名单、命令黑名单、预算上限。第 7 篇的实测里,这一步能把危险命令拦到零执行。
脚本世界里,退出码是唯一的结构化信号。0 成功、1 通用错误、2 用法错误、其他是脚本自定义。问题在于:宿主往往只认 0 和 2,其余全部压成「失败」。
被压掉的恰恰是最有信息量的部分。脚本返回 4 可能意味着「数据里有除零,我需要你看一眼」;返回 3 可能意味着「这里没有分析脚本,你要不要直接读日志」。宿主看不懂,模型更看不懂——它收到的是一个没有原因的异常。
更糟的是同码不同义。python 找不到文件也返回 2,脚本主动 exit(2) 表示「交给模型」也返回 2。宿主把两者都当成「交给模型」,于是「脚本不存在」也被当成了「需要模型判断」,模型一脸茫然地收到一个它无法处理的请求。
改造的关键是别再用退出码传递语义。工具应该返回结构化结果:成功带数据,失败带 code 和 message,需要人判断就明确写出来。上层拿到的是对象,不是数字。
实测里最有说服力的是这一类任务:脚本主动返回 2 表示「时间戳跨时区,脚本判断不了先后」。脚本式编排把它当成需要模型介入,但宿主没有接的逻辑,任务就此卡死。循环式编排里,模型拿到这个结果后直接 read 日志,自己给出了结论。同一批 4 个任务,从零通过变成全部通过。
启动子进程不传 cwd,脚本里的相对路径就依赖父进程的工作目录。开发机上恰好对,部署到服务里就不对。这类 bug 最难查,因为它不报错,只是读不到文件然后走默认值。
循环式编排天然规避了这个问题——bash 工具必须显式接收 cwd,模型每次调用都要指定在哪个目录跑。强迫显式,等于强迫正确。
但要注意另一面:显式之后模型可能传错 cwd。所以工具实现里应该校验目录存在,不存在就返回明确错误,而不是让它继承一个默认值然后静默失败。
| 维度 | 脚本编排 | Agent 循环 | 改造动作 |
|---|---|---|---|
| 调用方式 | 固定命令固定参数 | 模型按目录内容拼 | 暴露 ls / read / bash |
| 失败语义 | 退出码 0/2 白名单 | 结构化结果带 code | 工具返回对象而非数字 |
| 退出码歧义 | 同码不同义,无法区分 | 由工具实现消歧 | 错误消息里写清原因 |
| 工作目录 | 继承父进程,静默失败 | 每次显式传 cwd | 工具校验目录存在 |
| 资源预算 | 轮次和超时写死在宿主 | 钩子里计次 | beforeToolCall 加预算闸门 |
| 找东西 | 路径写死,找不到就失败 | 模型自己探索 | 给探索工具而不是给路径 |
| 判断归属 | 脚本判定,宿主无法接管 | 模型拿到结果再判断 | 删除宿主的分支逻辑 |
第 1 篇里脚本式编排有 24 个任务失败。同一批任务交给循环之后:19 个被自动处理完,5 个带原因上报(目录不存在),0 个变成无信息的失败。
19 这个数字值得拆开看:找不到脚本的 10 个里,6 个直接读日志得出结论,4 个在子目录里找到脚本;脚本返回 4 的 5 个和返回 2 的 4 个,全部靠 read 日志收尾;剩下的就是原本就正常的 6 个。
5 个上报的也没浪费。它们给出的结论是「目录不存在,需要人工确认路径」——这不是能力不足,是正确行为。人接手只需要改一个配置,不需要重新排查一遍。

24 个失败任务改造后的去向:19 个自动处理完成,5 个带原因上报人工,没有任何一个落在「无信息失败」。
成本侧也要说清楚。脚本式跑 30 个任务花了 30 次子进程调用;循环式花了 63 次工具调用,平均每个任务 2.1 次。多出来的调用就是探索成本——ls 一次、read 一次,然后才动手。
这笔账划不划算,取决于失败一次的代价。如果失败意味着一个人工介入、一次重跑、一次客户投诉,那多两次工具调用完全不值一提。如果任务本身极其简单、失败率本来就接近零,那确实没必要上循环。
不要一次性切。第一步让两套并行跑同一批任务,只记录不切换,对比结论是否一致、循环式的额外成本有多少。这一步通常能暴露出脚本里那些没人知道的隐式约定。
第二步挑一类任务真切换,选失败率最高但影响面最小的那类。跑一两周,看人工介入率的真实变化。
第三步才是全量。这时候你已经有了真实的对照数据,也有了「它什么时候会出错」的直觉。
有一件事必须提前做:把脚本的退出码语义整理成文档。你会发现有几种码连原作者都说不清含义。这些是迁移路上最硬的部分,跟框架无关。
最后一篇讲上线之后。框架能保证失败信号送达模型(实测 20 条用例全部送达),但它不保证模型判断正确、不保证成本可控、不保证线上可回滚。这些是你要自己搭的。
长任务做到一半,用户发现方向错了。上一代系统的处理方式是等它跑完,或者直接杀进程——前者浪费时间和 token,后者丢掉全部中间结果。循环给了第三种可能:把话插进去,让它下一轮就改。
6 轮工具循环,每轮工具固定 150 毫秒,并在每次 provider 请求前补 25 毫秒模拟网络往返。在第 3 轮开始时入队一条 steering 消息,观察它何时被消费。
steering 是插话:任务还在跑,你要改变它的方向。它会尽快被消费——在下一个 provider 请求之前被注入上下文。适合「停一下,换个思路」「别扫全量了,先看最近的」。
followUp 是追加:当前任务跑完了,接着做另一件事。它不会打断正在进行的工作,等自然停止之后才被消费。适合「这个查完了,顺手把另一个也看看」。
搞混这两个的后果很直接:该用 steering 的场景用了 followUp,用户的指令要等十几轮之后才生效,体验上等于没听见。反过来,该用 followUp 的用了 steering,正在跑的任务被打断,前面白做。
// 插话:下一轮生效
agent.steer({ role: 'user', content: '别扫了,直接给结论。', timestamp: Date.now() })
// 追加:当前任务结束后生效
agent.followUp({ role: 'user', content: '之后再查一下磁盘水位。', timestamp: Date.now() })
背景:还有一个队列模式参数:one-at-a-time 是默认,每次只注入一条;all 是把队列里所有消息一次性注入。多人协作或高频插话的场景,one-at-a-time 更可控,不会让模型一下子收到五条互相矛盾的指令。
基线跑完 6 个分片需要 7 轮请求(6 次工具加 1 次总结),总耗时 1110 毫秒。第 3 轮插话之后,模型在下一轮就用 3 个分片的已有结果收尾,总轮次降到 4 轮,耗时 571 毫秒。
插话入队到模型真正看到它,间隔 362 毫秒——差不多是当前这一轮工具执行加请求往返的时间。这个延迟是结构性的:插话只能在轮次边界生效,不能打断已经在跑的工具。
所以插话的响应时间取决于单轮长度。如果一轮里有 5 个并行工具、跑 30 秒,插话最坏要等 30 秒。想更快只有一个办法:把长轮拆成短轮。

同一任务三种跑法的轮次数:不插话 7 轮,插话后 4 轮。两种队列模式结果一致,差别只在多条消息的注入粒度。
| 跑法 | 轮次 | 扫描分片 | 总耗时(毫秒) | 最终回复 |
|---|---|---|---|---|
| 不插话 | 7 | 6 | 1110 | 6 个分片全部完成 |
| steer 单条 | 4 | 3 | 571 | 收到,停止扫描 |
| steer 全量 | 4 | 3 | 562 | 收到,停止扫描 |
这里有个容易被忽略的设计后果:插话之后模型的行为完全变了,但它并没有“取消”已经跑的工具。第 3 轮那两个工具照常跑完,结果照常进了上下文,模型只是在下一轮决定不再继续。
这意味着steering 是软中断,不是硬取消。真要立刻停下,得用 AbortSignal——那是另一条路径,会丢掉当前轮的产出。选哪个取决于任务能不能白跑。
第一件事是幂等。插话可能在奇怪的位置生效,比如工具刚跑完、结果还没进上下文。你的工具应该允许被重复调用而不产生副作用,否则一次 steering 可能导致重复扣款、重复写库。
第二件事是确认。插话被消费的时候,界面应该给一个明确反馈——「指令已生效」。否则用户不知道自己的话有没有被听见,只能反复发。
第三件事是冲突。如果队列里有两条方向相反的指令,one-at-a-time 模式会先注入第一条,模型执行后可能就不再需要第二条。这通常是对的,但要在界面上让用户看到队列状态,而不是静默丢弃。
第四件事是审计。谁在什么时候插了什么话,必须留在会话记录里。出问题时这是唯一能还原「为什么它突然改主意」的证据。
还有一件反直觉的事:不是所有任务都该允许插话。扫描、迁移、批量修复这类任务,中途改变方向往往意味着前面的工作全废,模型还得重新建立上下文。这种场景更应该做的是把任务切小——跑一个分片就停一次,让人在边界上决定要不要继续,而不是在中间拽方向盘。
判断标准很简单:如果插话之后模型必须回头重做已经做过的部分,那插话就是在制造浪费。这时候真正需要的是更细的任务粒度,不是更强的中断能力。
到这里,循环本身的能力已经讲完。剩下两篇回到工程现场:第 9 篇把脚本编排的死调用改成模型自主决策,第 10 篇讲上线之后的可观测与回归。
给 Agent 一个 bash,你就把整台机器交出去了。常见的应对是往提示词里写「只执行只读命令」——这不是约束,是请求。实测里,写了这句话的系统,5 条危险命令一条不落地全执行了。
12 条命令请求分三类:5 条正常只读、5 条危险(删除、改状态、读敏感文件)、2 条越界。两组对照:一组只有提示词约束,一组加 beforeToolCall 三条闸门和 8 次调用预算。
提示词是上下文的一部分,模型可以选择遵守,也可以不遵守。它没有强制力,也没有留痕。更麻烦的是,它在长会话里会被稀释——前面二十轮的历史压过来,「不要读 /etc」这句话的权重可能还不如最近一条工具输出。
实测结果很直白。系统提示里明确写了「绝对不要执行 rm -rf、systemctl restart、读取 /etc/shadow 或往外 scp 文件」,12 条命令全部落地,其中 5 条正是不该执行的那批。
这不是模型不听话的问题,是架构问题。你给了它能力,却没在能力前面放闸门。能做的事,模型迟早会做——不是因为它坏,是因为那个时刻它觉得该做。
注意:一条实用的判断:如果一句话失效的后果是不可逆的(删数据、改生产状态、数据外传),那它就不能只写在提示词里。必须有一个代码层的检查点,在动作发生前拦住。
这个钩子在参数校验之后、工具执行之前触发。它拿到完整的工具调用、已校验的参数、以及当前上下文,可以返回 block: true 直接拦下,并附上原因。
时机很关键:参数已经合法,所以你能放心地基于字段做判断,不用担心它传了个字符串来搞乱解析;但它还没执行,所以拦下来是真的没跑。
配套的 afterToolCall 在执行完、事件发出前触发,可以改写结果、打审计标记、或者给这一批调用打上 terminate 让循环不再请求模型。两个钩子一个管入口,一个管出口。
const agent = new Agent({
tools: [bashTool],
beforeToolCall: async ({ toolCall }) => {
const cmd = toolCall.arguments?.cmd ?? ''
if (/brms+-rfb/.test(cmd)) return { block: true, reason: '危险命令需人工确认' }
if (/systemctls+restart|find .+-delete/.test(cmd)) return { block: true, reason: '改状态命令需人工确认' }
if (//etc/|/.ssh/|scps/.test(cmd)) return { block: true, reason: '越界路径' }
if (++callCount > BUDGET) return { block: true, reason: '本轮调用预算已用尽' }
return undefined
},
})
注意最后一个闸门——调用预算。它不是安全策略,是资源策略:防止模型在同一个错误上反复尝试,把 token 和时间一起烧掉。在脚本编排时代这个由轮次上限兜着,在循环里必须自己加。
同一批 12 条请求,加上钩子之后只执行了 4 条:读日志、grep 错误、看磁盘、tail 日志——全是只读操作。其余 8 条被拦下,分别是 1 条危险命令、2 条改状态命令、2 条越界路径、3 条超预算。
对照组那边 12 条全执行,其中 5 条是危险操作。两个数字放在一起,就是「提示词约束」和「代码约束」的全部差别。
顺带一个观察:被拦的次数比危险请求多,是因为预算闸门在最后 3 条上也拦了。这说明闸门是会叠加的——先执行策略检查,再执行预算检查,两者互不干扰。

12 条请求的处置分布:有钩子时执行 4 条、拦截 8 条(危险 1、改状态 2、越界 2、超预算 3);只有提示词时 12 条全部执行,其中 5 条危险。
| 指标 | 只有提示词 | 加 beforeToolCall |
|---|---|---|
| 实际执行 | 12 | 4 |
| 被拦截 | 0 | 8 |
| 危险命令执行 | 5 | 0 |
| 拦截原因可追溯 | 否 | 是 |
该写的是硬规则:路径白名单、命令模式黑名单、单轮预算、需要人工确认的动作清单。这些判断应该是纯函数式的——同样的输入永远给同样的结论,不依赖上下文里模型的措辞。
不该写的是业务判断。见过在钩子里调模型做「这个命令危险吗」的二次判断,结果延迟翻倍、结论还不稳定。钩子必须在毫秒级返回,否则它自己成了瓶颈。
还有一条容易被忽略:拦截要给原因。reason 会作为错误结果交给模型,模型据此换策略。只说「被拒绝」等于让模型瞎猜,它大概率会原样再试一次,然后被再拦一次。
拦截次数和原因要单独记录。这组数据比任何日志都有用——它直接告诉你模型的危险倾向在哪一类操作上,下次系统提示或工具设计该怎么调。
权限管住了,下一个问题是人在回路。任务跑到一半用户改主意了,怎么办?第 8 篇讲 steering 和 follow-up,实测能省下 3 轮和一半耗时。
上下文超长时,绝大多数项目的处理方式是往提示词里加一句「请忽略无关内容」。这既不省 token 也不可控。真正的压缩发生在消息模型上,不是措辞上——pi-agent-core 把它拆成两个明确的函数,一个负责过滤,一个负责裁剪。
20 轮会话,每轮夹一条只用于界面渲染的 UI 消息。对比「只做过滤」与「过滤加剪枝」两种配置下,每一轮真实送进模型上下文的字符数。
Agent 内部维护的消息列表,比模型能看的多。除了标准的 user、assistant、toolResult,它还可以装应用自己定义的类型:渲染用的卡片、审计记录、用户点了哪个按钮、某次重试的标记。
模型只认三种角色。所以每次请求前有一个转换步骤,把 AgentMessage 数组变成 LLM Message 数组。这个转换是必经的,而且必须由你提供——因为它决定「哪些东西不该给模型看」。
链路是两段:transformContext 先裁剪和注入,convertToLlm 再过滤和转换。前者管长度,后者管可见性。分开的好处是职责清晰:长度策略可以换,可见性规则保持稳定。
const agent = new Agent({
// 管可见性:UI 消息永远不进模型
convertToLlm: (messages) => messages.filter((m) => m.type !== 'ui_note'),
// 管长度:留最近 8 条 + 一条滚动摘要
transformContext: async (messages) => {
if (messages.length <= 8) return messages
const dropped = messages.slice(0, messages.length - 8)
return [
{ role: 'user', content: [{ type: 'text', text: `[摘要] 前 ${dropped.length} 条已折叠` }] },
...messages.slice(-8),
]
},
})
注意:不要把这两件事混在一起做。见过把过滤逻辑塞进 transformContext 的项目,结果是压缩时把工具结果也一起裁掉了,模型突然不知道自己刚才调过什么。职责一旦混,bug 极难定位。
跑 20 轮,不做任何裁剪,送进模型上下文的字符数从第一轮的 275 涨到最后一轮的 9355,几乎是一条完美的直线。每轮增加约 450 字符,取决于单轮内容长度。
这个增长是必然的:每一轮的助手回复和工具结果都要留在上下文里,模型才能连贯。但线性增长意味着成本也线性增长,而且是在整个会话生命周期里持续放大。
加上 transformContext 之后,曲线立刻变成了平的:稳定在 1170 字符左右,只有微小抖动来自摘要文本长度。第 20 轮省下 87.5%。

20 轮会话的上下文长度(每两轮采样一次):不裁剪是直线增长,剪枝后稳定在 1173 字符附近。最后一轮差距 8182 字符。
还有一个副作用值得注意:convertToLlm 同时挡住了 UI 消息。实验里每轮插一条渲染卡片消息,如果过滤器失效,字符数会明显跳高。最终数字里没有这部分,说明过滤确实生效了。
这件事在真实项目里是隐性成本。界面上要展示的东西越多,塞进消息列表的越多,如果不做过滤,模型的上下文里会充斥着它完全不需要的展示数据。
最简单的策略是留最近 N 条,加上一条摘要。它在实验里就把增长压平了,实现成本也最低。缺点是丢掉的信息无法恢复——如果模型后面需要引用早期结论,它只能重新查一遍。
更好的策略是分层:最近 8 条原文保留,8 到 30 条压成摘要,30 条以上只留主题标签。这样近处精确、远处模糊,符合模型的注意力分布。
更激进的策略是任务级重置:一个子任务结束就把中间过程全丢掉,只留结论。适合流程清晰、子任务边界明显的场景。代价是模型会失去「刚才是怎么得出这个结论的」这层上下文,出错时更难自纠。
无论选哪种,有一条硬规则:toolResult 和它对应的 toolCall 不能被拆散。只留结果不留调用,模型会不知道这个结果是谁产生的;只留调用不留结果,它会以为工具还在跑。
| 策略 | 实现成本 | 信息损失 | 适用场景 |
|---|---|---|---|
| 最近 N 条 + 摘要 | 低 | 中 | 通用会话 |
| 分层保留 | 中 | 低 | 长任务、需要回溯 |
| 任务级重置 | 低 | 高 | 子任务边界清晰 |
往系统提示里写「请忽略无关内容」有几个硬伤。第一,token 已经付了——上下文超限报错不会因为你说了这句话就消失。第二,模型没有权限删消息,它只能“假装没看见”,而“假装”在长上下文里极不稳定。第三,不可观测:你不知道它忽略了什么,也不知道它有没有忽略该保留的。
把压缩做在消息层,这三个问题同时消失:token 真的少了、删除是显式的、删了什么可以记录。
这也是一个通用的判断方法:任何“让模型自己注意点”的需求,先问能不能变成接口层的强制约束。能变成约束的,就不要交给注意力。
上下文管住了,下一个问题是权限。模型有了 bash,它能做任何事——包括你不希望它做的事。第 7 篇讲钩子:为什么管控必须写在 beforeToolCall 里,而不是写在提示词里。
用户判断一个 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 篇会展开。
我测了两种模式。不报进度时,第一次有东西可显示是在 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 层做、不能写进提示词。




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