Agent 不是会调用工具就够了:我把一次运行拆成状态机、上下文和事件日志
大三刚开始时,我重新看了一遍自己前面写的几篇 Agent 文章,发现一个很明显的问题:我已经知道模型怎样调用工具,也知道 RAG 怎样把资料塞回上下文,但这些东西连起来以后,仍然更像一段能演示的脚本,不像一个可以长期运行的系统。
真正让我不舒服的是一次失败复盘。Agent 最后答错了,我只知道它调用过搜索和计算器,却说不清它为什么选择这个搜索词、为什么没有停在上一步、为什么拿到工具错误后还继续调用。一个系统只要无法解释自己的执行过程,我就很难相信它。
于是我开始把 Agent 从“模型加工具”拆成更底层的运行时:状态机负责推进,消息上下文只是状态的一部分,工具调用只是事件,日志记录每一次输入、决策、校验、执行和停止。模型仍然重要,但它不再是整套系统唯一的中心。
会调用工具只是入口,不是 Agent 运行时
我最早写的 Agent 循环大概是这样:
while 没有最终答案:
请求模型
如果模型要调工具:
执行工具
把结果塞回 messages
否则:
返回回答这个循环能跑通很多 demo,但它把太多事情揉在一起。模型请求、工具执行、错误处理、权限判断、上下文裁剪、日志记录和停止条件全挤在一个 while 里。短代码看起来清爽,调试时就会变成一团麻。
我后来给自己定了一个更严的标准:一次 Agent 运行必须能回答这些问题。
- 当前处于什么状态,是等待模型、等待工具、等待确认,还是已经失败
- 下一步允许发生哪些事件,哪些事件应该被拒绝
- 每个工具调用有没有经过参数校验和权限检查
- 上下文里哪些内容来自用户,哪些来自工具,哪些来自系统
- 达到停止条件时,停止原因是什么,而不是只返回一个空答案
- 如果进程中途崩掉,能不能从事件日志恢复到最近一个稳定状态
这些问题和模型聪不聪明没有直接关系,更像操作系统、网络协议和后端任务队列里的老问题。Agent 只是把它们带到了大模型应用里。
我把一次运行画成状态机,而不是一段循环
状态机的好处是把“现在能做什么”写清楚。一个最小 Agent 运行可以拆成这些状态:
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 不再包含全部过程。如果系统状态只存在消息里,裁剪就等于丢状态。
我后来把状态分成三层:
持久事件日志:
记录发生过什么,便于审计、恢复和回放
运行时状态:
当前状态、步数、预算、待确认动作、已完成工具结果
模型上下文:
从事件日志和运行时状态中投影出的一段文本或结构化消息模型上下文可以被压缩、重写和裁剪,但事件日志不能随便改。日志是事实,提示词是给模型看的工作台。
这也改变了我对“记忆”的看法。Agent 不是把所有东西塞进聊天记录就有记忆。真正的记忆至少要知道来源、时间、权限、是否过期,以及它在当前任务中承担什么角色。否则旧工具结果、用户临时偏好和系统规则混在一起,模型迟早会把数据当指令。
工具调用是意图,执行前必须变成受控动作
模型返回一个 tool call 时,我现在不会把它理解成“工具已经被调用”。它只是一个意图:
{
"name": "read_file",
"arguments": {
"path": "../secret.txt"
}
}意图进入运行时后,需要经过几步:
- 工具名是否存在
- 参数是否符合 schema
- 参数在业务上是否允许
- 当前用户是否有权限
- 当前状态是否允许调用这个工具
- 调用是否会产生副作用,是否需要确认
- 预算是否足够,例如剩余步骤、时间和费用
只有这些检查通过,意图才变成动作。这个边界必须由代码守住,不能靠提示词让模型“自觉不要乱来”。
我写过一个简单的工具调用记录结构:
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 必须是校验后的。这个区分看起来很小,但能避免很多思维混乱。模型给的是建议,运行时决定是否执行。
上下文压缩不是总结聊天,而是保留可执行状态
长任务会撞上上下文窗口。最简单的办法是把前面的消息总结成一段话:
到目前为止,我们已经查了 Redis 方案,预算为 1088 元。问题是,这样的总结容易丢掉执行细节。Agent 后面如果要判断是否重复创建工单,仅知道“已经查了预算”不够。它需要知道某个具体 action 是否成功、幂等键是什么、工具返回的 id 是什么。
我更倾向于把上下文压缩分成两类:
给模型看的语义摘要:
用户目标、当前发现、未解决问题、重要约束
给运行时用的结构化状态:
已执行 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 可以回放,而不是只能截图复盘
我给每次运行设计的事件大概有这些:
{
"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。这些都不是手动体验能稳定发现的。
模型输出也应该被版本化
如果只存最终答案,不存模型版本和提示词版本,几周后很难解释一次回归。
我会给运行记录保存这些信息:
{
"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,这时就不应该让模型自由并发。
我给工具加了依赖和风险等级:
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。它不知道上一次已经完成了副作用,除非运行时把这个事实保存下来。
所以恢复要基于事件日志,而不是基于原始用户输入。系统重启后先读取最后一个稳定事件:
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 接口。它不关心具体是写博客、查资料还是修代码,只关心运行怎样推进:
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,看似方便,实际既浪费又不安全。
我会把预算写进运行配置:
{
"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。它会成为一个能解释、能测试、能回放的系统。