跳至主要内容
Agent 的确定性来自状态机

Agent 的确定性来自状态机

我最早做的那个 Agent 只有十几行:一个 while 循环,调模型,解析工具调用,执行,把结果塞回上下文。本地跑得挺好。

后来拿它去整理一个几万行的仓库,问题就来了。某次文件读取失败,它开始重复同一个调用,参数一模一样,报错一模一样,四分钟烧掉一大截额度,最后把上下文撑爆。我盯着日志想,这不是模型的问题,是我没有给它留下“失败之后去哪儿”的位置。

那份泄露的源码我看了两遍,收获不在提示词,在状态。

把不确定性关进有限个状态

它的生命周期被切成几段:构建上下文、编排工具、流式执行、检查点与恢复。分段本身不稀奇,稀奇的是每条失败路径都有明确的落点。

参数解析不出来,不是抛异常让上层接住,而是进入一个专门处理解析错误的状态,让模型带着错误信息重规划一次。重试计数区分“第一次失败”和“重试又失败”,因为这两者的原因经常不同。某个工具连续失败到阈值,熔断器直接切断它,不再让模型去试,状态挂起等人工介入。

用伪代码说,大概是这个样子:

TS9 行
1
2
3
4
5
6
7
8
9
while (state !== 'End' && state !== 'Error') {
switch (state) {
case 'Plan': state = plan(ctx); break;
case 'Execute': state = await exec(ctx); break; // 失败时 attempt += 1
case 'Recover': state = recover(ctx); break; // 回滚到最近的健康快照
case 'Checkpoint': save(ctx); state = 'Plan'; break;
}
if (ctx.attempt > 3) state = 'WaitingForUser';
}

真正起作用的不是这个循环,是循环里每个分支都能穷举。写业务代码时我习惯相信“异常会被上层处理”,在 Agent 里这个假设不成立,因为上层也是概率性的:模型看到一段报错,可能修正,也可能换个方式犯同一个错。

会话引擎那一侧的状态也说明同样的取向。消息队列、中断控制器、权限拒绝记录、token 用量、文件读取缓存,全部显式挂在一个对象上,而不是散落在闭包里。我最初觉得这是 Java 式的啰嗦,写到第三个工具才发现,只要有一个工具偷偷改了全局变量,回滚就失效了。状态必须可枚举,才能被存、被还原、被比对。

检查点那一层我一开始没做,觉得重。后来发现它解决的是另一个问题:报错堆栈会污染上下文。把几十行异常信息喂回去,模型往往不是变聪明,而是被带偏,然后在错误的分支上越走越远。回滚到犯错前的快照,再注入一句短得多的补偿指令——“这个路径不存在,先列目录再决定”——成功率明显不一样。

代价是内存和实现复杂度。每个健康节点都要深拷贝上下文、token 统计、状态树;长会话里这些东西不小。我现在的做法是只在跨越“计划到执行”这种边界时存快照,不是每步都存。省下来的开销换来的是恢复粒度变粗,可以接受。

还有一个细节:并行执行的工具会在状态上打架。多个调用同时读写同一个文件、同时往同一个日志里追加,回滚的时候谁先谁后说不清。它那边的做法是把互不依赖的调用合成批次并跑,有依赖的严格串行,批次之间等齐再往下走。这个规则简单到可以用一段代码写完,但它把“并发”从一个概率问题变成了一个顺序问题。

超时也要分层。工具自己有超时,状态机有整体超时,用户还有耐心上限。三层如果只做一层,最常见的现象是:工具在跑,界面没反应,用户以为卡死,按了两次 Ctrl-C,最后留下的状态谁也说不清。

流式输出真正的难点是状态

终端界面那部分我起初觉得是过度设计:一个命令行工具,用 React 渲染,还带一套自定义协调器。

自己写过一个流式打印的 CLI 之后就不这么想了。一边逐字输出,一边显示三个工具的执行进度,一边准备 diff 预览,还要保证用户随时能插话打断。用命令式的光标控制去维护这些局部刷新,代码会在第二周变成一团。

它的做法是把状态分成两层:会话级的东西——消息历史、整体状态、检查点——更新频率低;当前正在流式生成的那一段单独放,更新频率高。顶层组件只订阅前者,负责打字机效果的子组件用细粒度选择器订阅后者。

这个区分很关键。我踩过的坑是每来一个 token 就更新一次全局状态,结果整棵组件树跟着重渲染,界面开始卡,严重的时候会吞字。改成只更新当前节点、再配一层节流之后,问题消失。极端高频的输出甚至会先攒在缓冲区里,按固定帧率刷到屏幕上。

中断是另一个容易漏的地方。用户按 Ctrl-C 之后,界面要立刻停,后台的工具却不能硬砍——正在写的文件会留下半截。我后来的做法是分两步:先把状态切到“正在停止”,停止渲染新的 token,再给工具一个短暂的收尾窗口,超时才强杀。这个中间态在 UI 上很难看,但比数据损坏好。

它那套渲染是这么做的:写一个自定义的 React 协调器,接到 Yoga 上做 flexbox 布局,把虚拟节点 diff 完再翻译成 ANSI 转义序列吐给终端。我刚看到时觉得是过度工程,后来意识到难点在“局部重绘”:终端没有浏览器的合成层,一次全量清屏就会闪。把样式、文本属性、事件处理器分开追踪,是为了让事件句柄变化不触发重绘。这类优化只有在写了几千行终端 UI 之后才会想到。

取舍也明显:引入 React 运行时,冷启动变慢,状态树、缓冲区、依赖进程全堆在一个终端进程里,内存占用不会低,社区对这一点一直有抱怨。我倾向接受,因为状态复杂度已经接近一个单页应用,用命令式代码硬扛的维护成本更高。如果只是一个打印日志的小工具,这套东西就是纯负担。说到底,这是把复杂度从“用户理解”转移到了“运行时”,终端里的东西看起来简单,账在看不见的地方。

前台之外还有一个后台

源码里有个叫 KAIROS 的守护进程,被特性开关藏着。它做的事情不神秘:按天写交互日志,在空闲时整理记忆,支持把会话挂到后台继续跑。

几个约束值得记:后台任务有 15 秒的阻塞预算,超了就从主流程剥离出去;合并记忆时加文件锁,避免多个实例同时写同一个文件;后台进程被限制成只读,只能碰自己的记忆目录,不碰用户的项目源码。

这三个约束合起来说明一件事:它不是“更聪明的助手”,是一个被严格限制权限的维护进程。这个定位很克制,也是我认为它可信的原因。

配套的命令也很有意思,ps、logs、attach、kill,几乎是把容器编排的直觉搬进了终端。对习惯 tmux 的人来说这是自然的:会话不是一次性的问答,是一个可以挂起、重连、杀掉重来的东西。我暂时用不上,但它提示了一个方向——Agent 的交互形态可能更像服务,而不是对话框。

记忆整理那套机制,思路和 RAG 相反。RAG 是切片、向量化、按相似度召回,好处是容量可以外挂,坏处是切断时序——召回了一堆相关片段,却丢了“当初为什么选这个方案”。它选择在原上下文里做压缩:把几千行 grep 输出剪掉,只留结论;把多轮试错折叠成一句“改了组件 A 没用,最后是依赖 B 的版本冲突”;把当前任务强相关的路径和决策钉在提示词顶部。

这个方案的优势是不依赖外部向量库,推理链条保留得完整;劣势是要花后台算力,压缩本身也要调模型,是隐性成本,而且最终容量还是受窗口限制。

这三种动作的代价不一样。剪枝最安全,信息丢了但可以重跑;语义压实会引入判断,压错一次,后面所有推理都建在错误的前提下;实体常驻最省事,但它把上下文钉死了,换任务时要记得清掉。我如果自己做,会先做第一种,观察一段时间再考虑第二种。

这也解释了为什么我不太相信“无限记忆”这种说法。压缩的每一层都引入一次判断,判断就会出错,层数越多,出错的地方越靠前,也越难发现。

我暂时不打算在自己的项目里复刻。十轮以内的对话,滑动窗口够用;加上后台压缩进程之后,它和用户突然插话之间的竞态条件会立刻变成新的 bug 来源。这类机制适合长周期、上下文必然膨胀的任务,不适合当默认配置。

权限边界是最后一关

前面那些都是性能和维护性问题,这一段是会不会出事的问题。

当一个 Agent 能读文件、跑 shell、连 MCP 服务,它的攻击面就从“模型说错话”变成了“在本机执行任意命令”。典型的路径是这样:Agent 去读一个仓库的 README 或者一个网页,里面藏着指令;上下文被污染,模型把它当任务;状态机进入执行阶段,调了 shell 工具,参数是拼出来的;本机没有沙箱,命令就跑了。

中间任何一环都可以拦。参数白名单、路径校验、只读挂载、出站流量限制,都是工程上做得到的。我更喜欢的一个位置是在计划和执行之间插一个挂起状态:涉及删除、写系统目录、发网络请求这类操作时,不直接执行,先把意图打给用户,等一个明确的确认。

这条会牺牲流畅度,尤其是在批量任务里,确认弹窗会变得很烦。我的判断是核心链路上值得,边缘操作可以放行。至于“绝对安全”,我不信这个说法——只要模型能碰环境,就存在绕过沙箱的可能。能做的是假设它会失控,然后提前想好失控时的止损点。

我如果去跑别人的代码,会把环境整个当成不可信:用一次性的容器或虚拟机,不在有真实凭据的机器上跑;容器里不给 root;不需要联网的调试就把出站断掉;环境变量里放假的 key;仓库里的 postinstall 之类脚本先关掉再看。这几条都很土,但它们挡的是最常见的一类事故——把别人的代码当成教程跑了一遍,顺手把本地配置和密钥一起交了出去。

看完那份源码,我对自己项目的改动很有限:加了一个显式的失败状态,加了重试上限,把工具调用参数过了一遍白名单。界面没动,后台进程没加。倒不是那些设计不好,是我这个规模用不上——为十轮对话的机器人搭一套守护进程,只会让调试变难。

关于本文

由 Lu's Blog 撰写,采用 CC BY-NC 4.0 许可协议。

#Agent #工程架构 #状态机

评论