Agent 不是会调用工具就够了:我把一次运行拆成状态机、上下文和事件日志

#AI Agent#Agent Runtime#状态机 共 5,412 字 约 17 分钟

大三刚开始时,我重新看了一遍自己前面写的几篇 Agent 文章,发现一个很明显的问题:我已经知道模型怎样调用工具,也知道 RAG 怎样把资料塞回上下文,但这些东西连起来以后,仍然更像一段能演示的脚本,不像一个可以长期运行的系统。

真正让我不舒服的是一次失败复盘。Agent 最后答错了,我只知道它调用过搜索和计算器,却说不清它为什么选择这个搜索词、为什么没有停在上一步、为什么拿到工具错误后还继续调用。一个系统只要无法解释自己的执行过程,我就很难相信它。

于是我开始把 Agent 从“模型加工具”拆成更底层的运行时:状态机负责推进,消息上下文只是状态的一部分,工具调用只是事件,日志记录每一次输入、决策、校验、执行和停止。模型仍然重要,但它不再是整套系统唯一的中心。

会调用工具只是入口,不是 Agent 运行时

我最早写的 Agent 循环大概是这样:

Text
UTF-8|7 Lines|
while 没有最终答案:
  请求模型
  如果模型要调工具:
    执行工具
    把结果塞回 messages
  否则:
    返回回答

这个循环能跑通很多 demo,但它把太多事情揉在一起。模型请求、工具执行、错误处理、权限判断、上下文裁剪、日志记录和停止条件全挤在一个 while 里。短代码看起来清爽,调试时就会变成一团麻。

我后来给自己定了一个更严的标准:一次 Agent 运行必须能回答这些问题。

  • 当前处于什么状态,是等待模型、等待工具、等待确认,还是已经失败
  • 下一步允许发生哪些事件,哪些事件应该被拒绝
  • 每个工具调用有没有经过参数校验和权限检查
  • 上下文里哪些内容来自用户,哪些来自工具,哪些来自系统
  • 达到停止条件时,停止原因是什么,而不是只返回一个空答案
  • 如果进程中途崩掉,能不能从事件日志恢复到最近一个稳定状态

这些问题和模型聪不聪明没有直接关系,更像操作系统、网络协议和后端任务队列里的老问题。Agent 只是把它们带到了大模型应用里。

我把一次运行画成状态机,而不是一段循环

状态机的好处是把“现在能做什么”写清楚。一个最小 Agent 运行可以拆成这些状态:

Text
UTF-8|28 Lines|
Created
  │ 接收用户目标

Planning
  │ 模型生成工具调用或最终回答
  ├── final answer ───────────────► Completed
  ├── tool calls ─────────────────► ValidatingToolCalls
  └── model error ────────────────► Failed

ValidatingToolCalls
  │ 工具存在、参数合法、权限允许
  ├── valid ──────────────────────► ExecutingTools
  └── invalid ────────────────────► Failed 或 WaitingForUser

ExecutingTools
  │ 执行只读工具或带副作用工具
  ├── all done ───────────────────► Observing
  ├── needs approval ─────────────► WaitingForUser
  └── timeout / error ────────────► Recovering

Observing
  │ 把工具结果写入事件流与上下文
  └───────────────────────────────► Planning

Recovering
  │ 判断能否重试、回滚或降级
  ├── retry ──────────────────────► ExecutingTools
  └── give up ────────────────────► Failed

这样写之后,很多含糊的逻辑会被迫变清楚。比如模型返回一个不存在的工具名,到底是让模型重新计划,还是直接失败?工具参数缺字段,是交给模型修正,还是由代码补默认值?写操作需要确认时,系统应该停在 WaitingForUser,而不是继续把“用户可能同意”当成事实。

状态机不是为了显得架构复杂。它的价值是让错误没有缝可以钻。任何事件进来时,系统先检查当前状态是否允许这个事件。如果 Agent 已经 Completed,后面又来了一个迟到的工具结果,就不应该再改变最终答案。

消息历史不是状态,它只是状态的一种投影

早期我把 messages 当成 Agent 的全部记忆。每次模型、用户和工具的内容都 append 进去,下一轮再把整个数组发给模型。这个做法简单,但会带来两个问题。

第一,消息历史适合模型阅读,不一定适合系统恢复。比如一个工具调用失败了三次,消息里可能只看到三段错误文本,却没有结构化记录每次开始时间、超时时间、重试原因和 idempotency key。

第二,上下文窗口有限,运行一长就必须裁剪。裁剪后的 messages 不再包含全部过程。如果系统状态只存在消息里,裁剪就等于丢状态。

我后来把状态分成三层:

Text
UTF-8|8 Lines|
持久事件日志:
记录发生过什么,便于审计、恢复和回放

运行时状态:
当前状态、步数、预算、待确认动作、已完成工具结果

模型上下文:
从事件日志和运行时状态中投影出的一段文本或结构化消息

模型上下文可以被压缩、重写和裁剪,但事件日志不能随便改。日志是事实,提示词是给模型看的工作台。

这也改变了我对“记忆”的看法。Agent 不是把所有东西塞进聊天记录就有记忆。真正的记忆至少要知道来源、时间、权限、是否过期,以及它在当前任务中承担什么角色。否则旧工具结果、用户临时偏好和系统规则混在一起,模型迟早会把数据当指令。

工具调用是意图,执行前必须变成受控动作

模型返回一个 tool call 时,我现在不会把它理解成“工具已经被调用”。它只是一个意图:

JSON
UTF-8|6 Lines|
{
  "name": "read_file",
  "arguments": {
    "path": "../secret.txt"
  }
}

意图进入运行时后,需要经过几步:

  1. 工具名是否存在
  2. 参数是否符合 schema
  3. 参数在业务上是否允许
  4. 当前用户是否有权限
  5. 当前状态是否允许调用这个工具
  6. 调用是否会产生副作用,是否需要确认
  7. 预算是否足够,例如剩余步骤、时间和费用

只有这些检查通过,意图才变成动作。这个边界必须由代码守住,不能靠提示词让模型“自觉不要乱来”。

我写过一个简单的工具调用记录结构:

TypeScript
UTF-8|16 Lines|
type ToolIntent = {
  id: string;
  step: number;
  name: string;
  arguments: unknown;
  source: 'model';
};

type ToolAction = {
  id: string;
  step: number;
  name: string;
  arguments: Record<string, unknown>;
  risk: 'read' | 'write' | 'external';
  idempotencyKey?: string;
};

ToolIntent 可以是不可信的,ToolAction 必须是校验后的。这个区分看起来很小,但能避免很多思维混乱。模型给的是建议,运行时决定是否执行。

上下文压缩不是总结聊天,而是保留可执行状态

长任务会撞上上下文窗口。最简单的办法是把前面的消息总结成一段话:

Text
UTF-8|1 Line|
到目前为止,我们已经查了 Redis 方案,预算为 1088 元。

问题是,这样的总结容易丢掉执行细节。Agent 后面如果要判断是否重复创建工单,仅知道“已经查了预算”不够。它需要知道某个具体 action 是否成功、幂等键是什么、工具返回的 id 是什么。

我更倾向于把上下文压缩分成两类:

Text
UTF-8|5 Lines|
给模型看的语义摘要:
用户目标、当前发现、未解决问题、重要约束

给运行时用的结构化状态:
已执行 action、结果 id、权限范围、剩余预算、停止原因

语义摘要可以让模型继续推理,结构化状态保证系统不会忘记事实。二者都可以从事件日志生成,但用途不同。

例如一个研究型 Agent 已经访问了五篇文档。给模型看的摘要可以写“前三篇都认为方案 A 延迟更低,第四篇指出成本更高”。给运行时的状态则要保留每篇文档的来源、访问权限和引用编号。最后输出引用时,不能让模型凭摘要编来源。

停止条件应该像刹车一样提前设计

我以前把停止条件当作防止死循环的小补丁,通常只有一个 max_steps。后来发现这远远不够。Agent 的停止原因至少应该可分类:

停止原因含义
final_answer模型给出最终答案
max_steps达到最大步骤数
time_budget_exceeded超过总耗时
cost_budget_exceeded超过 token 或费用预算
duplicate_action重复调用同一工具和参数
approval_required高风险动作需要用户确认
tool_unavailable关键工具不可用
policy_violation权限或安全规则拒绝

这些停止原因会影响最终回答。max_steps 应该告诉用户任务没有完成,approval_required 应该展示待确认动作,policy_violation 不能伪装成“没有找到资料”。

停止条件还会影响测试。测试一个 Agent 时,我不只断言回答内容,也断言 stop reason。否则一个任务因为超步数退出,却刚好输出了部分正确答案,测试可能误判通过。

事件日志让 Agent 可以回放,而不是只能截图复盘

我给每次运行设计的事件大概有这些:

JSON
UTF-8|12 Lines|
{
  "type": "tool.finished",
  "run_id": "run_20240914_001",
  "step": 3,
  "tool": "search_notes",
  "arguments": {
    "query": "Redis 预算"
  },
  "result_ref": "blob_17",
  "duration_ms": 142,
  "created_at": "2024-09-14T09:13:22.103Z"
}

工具返回值可能很大,也可能包含敏感信息,所以日志里不一定直接存全文,可以存引用和脱敏摘要。关键是事件必须足够还原过程。

有了事件日志,我可以做三件以前做不到的事。

第一,失败复盘。答案错了,可以看到模型每一步看到了什么、选了什么工具、工具返回了什么。不是猜 Prompt 哪里不够强。

第二,离线回放。把同一段工具结果回放给新版本 Agent,看它是否会做出不同决策。这样能把外部世界的变化和 Agent 逻辑变化分开。

第三,统计分析。比如某个工具的错误率升高,某类问题总是触发最大步数,某个 Prompt 版本让平均步骤数从 4 增加到 9。这些都不是手动体验能稳定发现的。

模型输出也应该被版本化

如果只存最终答案,不存模型版本和提示词版本,几周后很难解释一次回归。

我会给运行记录保存这些信息:

JSON
UTF-8|9 Lines|
{
  "runtime_version": "agent-runtime-v0.4",
  "model": "具体模型版本",
  "system_prompt_version": "planner-v6",
  "tool_schema_version": "tools-v3",
  "retrieval_index_version": "notes-2024-09-10",
  "temperature": 0,
  "max_steps": 8
}

这不是形式主义。Agent 的行为由模型、提示词、工具描述、索引和运行时一起决定。任何一项变化都可能让结果不同。没有版本信息,评估报告里的成功率数字只是一个无法复现的截图。

我也开始避免说“模型今天变笨了”这种模糊判断。更可检查的说法是:在同一测试集、同一工具回放、同一评分规则下,planner-v7 相比 planner-v6 在权限类场景失败数从 0 增加到 3。这样的结论才能推动修复。

并发工具调用让状态问题更明显

有些模型一次会返回多个工具调用,例如同时搜索两份资料。并发能降低延迟,也会让状态管理更复杂。

如果两个工具都只读,问题不大。它们可以并行执行,结果全部回来后再进入下一轮规划。

但如果其中一个是写操作,另一个依赖写操作的结果,并发就危险了。比如同时“创建工单”和“给工单添加评论”,评论工具需要 ticket id,这时就不应该让模型自由并发。

我给工具加了依赖和风险等级:

Text
UTF-8|5 Lines|
read-only search       可以并发
read-only calculate    可以并发
write create_ticket    需要幂等键,可能需要确认
write add_comment      依赖 ticket_id,不能早于 create_ticket
external send_email    需要用户确认

运行时看到多个 tool calls 时,不是全部扔进 Promise.all,而是先按风险和依赖分组。能并发的并发,不能并发的转成待确认或失败。模型可以提出多个意图,调度权仍然在运行时。

恢复不是从头再来,而是从稳定事件继续

我以前处理失败最直接的办法是“再跑一次”。普通问答这样做问题不大,Agent 一旦接入工具,从头重跑就可能产生更多错误。

假设一次任务是:读取需求、创建 issue、生成分支名、提交修改、回复用户。运行到创建 issue 后进程崩了。如果下次直接把用户输入再交给模型,模型可能重新创建一个 issue。它不知道上一次已经完成了副作用,除非运行时把这个事实保存下来。

所以恢复要基于事件日志,而不是基于原始用户输入。系统重启后先读取最后一个稳定事件:

Text
UTF-8|5 Lines|
run started
model requested create_issue
tool create_issue succeeded, issue_id = 17
model requested edit_file
process crashed before edit_file finished

恢复时不能再次执行 create_issue,而应该从 edit_file 之前的状态继续。对于已经成功的写操作,只能读取结果或用幂等键确认,不能凭感觉重放。

这让我把事件分成两类:意图事件和事实事件。模型提出 create_issue 是意图,工具返回 issue_id = 17 是事实。恢复时只能相信事实事件,不能把模型意图当作已经完成。

如果某个工具调用处在“不知道是否成功”的状态,例如请求超时且下游没有返回结果,运行时应该进入 Recovering,调用查询状态的工具,或者要求人工确认。最危险的处理是自动重试一个不具备幂等性的写操作。

这个问题和数据库事务很像。Agent 的每一步动作都在改变外部世界,运行时必须知道哪些变化已经提交,哪些只是计划,哪些状态不确定。模型可以帮助解释失败,但不能替代提交日志。

运行时接口要小,否则每个应用都会重新发明一遍

拆完状态机后,我开始给自己设计一个很小的 Agent Runtime 接口。它不关心具体是写博客、查资料还是修代码,只关心运行怎样推进:

TypeScript
UTF-8|7 Lines|
type AgentRuntime = {
  start(input: UserInput): Promise<RunId>;
  appendEvent(runId: RunId, event: RunEvent): Promise<void>;
  getState(runId: RunId): Promise<RunState>;
  step(runId: RunId): Promise<RunState>;
  cancel(runId: RunId, reason: string): Promise<void>;
};

应用层负责定义工具和任务目标,运行时负责状态推进、事件落盘和预算控制。这样做的好处是,后面无论写 RAG 助手、代码修复 Agent,还是测试生成 Agent,都不用重新写一遍“最大步数”“工具日志”“停止原因”“确认事件”。

我一开始总想把运行时做得很通用,后来发现通用不是功能多,而是边界稳定。只要事件格式、状态转换和工具网关稳定,上层应用可以变化。反过来,如果每个应用都直接操作消息数组和工具函数,最后会出现三套不同的重试逻辑、四种不同的日志格式,以及一堆无法互相复用的测试。

这个接口还逼我区分“推进一步”和“跑到结束”。step 只做一次状态转换,方便测试和调试;runUntilDone 可以由外层循环调用。测试状态机时,我可以构造事件,调用一次 step,断言状态从 Planning 变成 ValidatingToolCalls。这比启动一个完整 Agent 再观察最终回答清楚得多。

越往底层写,我越觉得 Agent 工程不应该先追求框架花样。把运行时接口做小,事件写清楚,状态能恢复,已经能解决很多真实问题。

预算不是性能指标,而是运行时边界

我以前把 token、步骤数和耗时看成性能指标,只有在账单变高或用户等太久时才关注。后来发现它们其实也是安全边界。

一个 Agent 如果可以无限调用模型和工具,就等于允许一次错误规划持续扩大。它可能反复搜索同一个关键词,也可能在工具报错后不断换参数尝试。即使每一步都是只读操作,最后也会消耗大量费用和时间。如果工具有副作用,风险更大。

所以预算要进入状态机,而不是放在监控面板里事后查看。每次进入 Planning 前,运行时检查剩余模型调用次数;每次执行工具前,检查剩余工具预算和总耗时;每次准备写入外部系统前,检查当前任务是否仍在授权窗口内。

预算还要按任务类型区分。一个简单问答最多两轮模型调用就应该结束。一个研究任务可以允许十几轮检索和总结。一个写代码任务可能需要多次运行测试,但每次测试都要有独立超时。把所有任务套同一个 max_steps = 20,看似方便,实际既浪费又不安全。

我会把预算写进运行配置:

JSON
UTF-8|7 Lines|
{
  "max_model_calls": 8,
  "max_tool_calls": 12,
  "max_wall_time_ms": 120000,
  "max_write_actions": 1,
  "max_retries_per_tool": 2
}

这些限制不只是为了省钱。它们让系统在模型迷路时能停下来,并给用户一个具体解释:“我已经检索了 6 次仍然没有找到足够证据”,而不是继续装作正在努力。

预算也能暴露设计问题。如果一个场景经常因为步数耗尽失败,可能不是预算太小,而是工具粒度不对、检索召回差,或者提示词没有让模型形成稳定策略。运行时把预算消耗记录下来,才能看到这些问题。

我开始把 Agent 当成小型操作系统

这个比喻不完全准确,但对我很有帮助。操作系统不相信进程会自觉守规矩,所以提供权限、调度、文件系统边界、日志和终止机制。Agent 运行时也不应该相信模型永远做对。

模型像一个会提出计划的进程。工具是系统调用。权限模型决定哪些调用允许发生。事件日志像审计记录。上下文管理像内存管理。停止条件像调度器的时间片和资源限制。

这个视角让我少了很多玄学。我不再纠结“Agent 是否真正理解目标”,而是问更具体的问题:

  • 它这一步依据了哪些输入
  • 它提出的动作是否被验证
  • 它能访问哪些资源
  • 它失败后会怎样恢复
  • 它的执行过程能不能被回放

这些问题比“智能程度”更适合工程实现,也更适合面试时解释自己的项目。

底层拆完以后,应用文章才有根

我后来意识到,写 Agent 应用很容易显得浮。做一个“自动整理资料的助手”“自动写测试的助手”“自动分析日志的助手”,听起来都不错,但如果底层运行时说不清,应用文章就会像功能介绍。

所以我决定从大三开始,把 Agent 方向按由下到上写:先写运行时,说明一次任务如何被表示、推进和记录;再写工具权限和沙箱,说明 Agent 接触真实系统时怎样不越界;最后写评测台,说明我如何证明改动真的让 Agent 更可靠。

这篇算是第一层。它没有做一个漂亮应用,只是在回答一个底层问题:当模型不再只是说话,而是进入循环、读取工具结果、产生动作时,外层系统应该怎样组织。

我的结论很直接:Agent 不是一个模型名,也不是一段提示词。它是一个把概率决策接入确定性软件的运行时。模型负责提出下一步,状态机负责推进,工具网关负责执行边界,事件日志负责留下证据。

把这些层拆清楚以后,后面的应用才不会只是在模型外面包一层 UI。它会成为一个能解释、能测试、能回放的系统。