3. 工具系统与执行编排
这是什么:工具系统 = Agent 能调用的那些动作;执行编排 = 决定这些动作按什么顺序、什么方式发生。
为什么用:模型再强,接口设计得差也白搭——同一个模型换个编辑接口,评测得分能从 7% 变成 68%。工具就是 Agent 的手。
如何用:本章先讲工具描述和结果怎么写,再讲编排层怎么把多步骤任务串起来,最后看混合状态机和钩子这两种工程手法。
3.1 工具接口决定了任务完成率
回到那个最说明问题的实验:Can.ac 的编码评测里,模型一个字没改,只替换了文件编辑接口,得分从 6.7% 跳到 68.3%。
这说明工具接口的设计要满足几条:
| 要求 | 说明 | 反例 |
|---|---|---|
| 粒度合适 | 一次调用能完成一个完整意图 | 让 Agent 手写 diff 头 |
| 参数可推断 | 模型不看文档也知道该填什么 | 参数叫 p1/p2 |
| 结果可读 | 输出能直接指导下一步 | 只回 Error: failed |
| 错误可行动 | 失败时给出「怎么改」 | 抛原始异常栈 |
粒度这一条最容易被忽略。给 Agent 一个 edit_file(path, old_string, new_string),它得自己在脑子里拼 diff;给一个 apply_patch(patch),它至少得自己写 diff。粒度越接近「人类意图」,模型越不容易错。
参数可推断也一样重要。工具描述是模型唯一的说明书:
TOOL_SCHEMAS = [{
"type": "function",
"function": {
"name": "run_tests",
"description": "运行项目的测试并返回失败明细。用户要求验证、跑测试、检查改动是否正确时使用。",
"parameters": {
"type": "object",
"properties": {
"target": {
"type": "string",
"description": "要测试的路径或包名,例如 'server/tests/test_users.py'。留空表示跑全部。",
},
"timeout_seconds": {
"type": "integer",
"description": "超时秒数,默认 120",
},
},
"required": [],
},
},
}]
注意 description 里带了触发场景(「用户要求验证、跑测试时使用」),target 里给了具体例子——这跟 技能 Skills 里 description 的写法是同一个道理。
3.2 工具结果要「可读」
模型看到的是工具返回的字符串。这段字符串的质量,直接决定下一步的成败。
对比两种错误返回:
# ❌ 差:模型只知道失败了,不知道怎么办
return f"Error: exit code {code}"
# ✅ 好:失败原因 + 下一步建议
return (
f"测试失败:3 个用例未通过(退出码 {code})\n"
f"失败清单:\n{failed_lines}\n"
f"建议:先修这 3 个用例;若为环境问题(如缺少依赖),"
f"先跑 `pip install -r requirements.txt` 再重试。"
)
第二条多出来的几个字,是让 Agent「别在同一处反复试错」的关键。
日志格式也一样重要。 Carlini 那个用 16 个 Agent 写 C 编译器的项目里有个细节:日志写到文件而不是控制台,并且采用 grep 友好的单行格式,例如 ERROR: [reason]。需要诊断时再检索文件——这样无关日志不会持续占用上下文。
🤝 记忆口诀:结果要能指导下一步,错误要能指导下一步。
3.3 执行编排:让模型按轨道走
工具是「手」,编排层是「让它按什么顺序动手」。
一个稳妥的推进轨道是:
理解目标 → 判断还缺什么信息 → 分析 → 生成 → 检查
对应到实现上,通常是三类控制手段:
| 手段 | 作用 | 典型配置 |
|---|---|---|
| 系统提示约束 | 规定「先想清楚再动手」 | 「动手前先列出要改的文件」 |
| 工具选择策略 | 控制什么时候允许调工具 | 规划阶段禁用写文件 |
| 停止条件 | 什么时候算收敛 | 连续 3 轮无新信息就停 |
第 2 条值得展开:把「规划」和「执行」分成两个阶段,是 Harness 里性价比很高的一招。规划阶段只给只读工具(读文件、搜索、列目录),产出计划;执行阶段再放开写工具。这样做的好处:
- 模型不会「边想边改」,把仓库改得面目全非
- 计划本身可以被人类看一眼再放行
- 出问题时更容易定位是「想错了」还是「做错了」
3.4 混合状态机:该死板的死板,该灵活的灵活
Stripe 的 Minions 是跑在大型既有代码库上的无人值守系统,每周三周合并 1300+ 个完全由 Agent 生产、人类没写一行代码的 PR。拆开看,它靠的不是「更强的模型」,而是一套成熟的编排环境:
| 组件 | 关键设计 |
|---|---|
| Devbox | AWS EC2 预装源码和服务,预热池分配,启动约 10 秒 |
| 编排状态机 | 混合节点:lint、push 这类走确定性流程;实现功能、修 CI 错误这类走 Agent |
| Toolshed MCP | 集中式 MCP 服务,近 500 个工具,每个 Minion 拿到筛选后的子集 |
| 反馈回路 | pre-push hook 秒级修 lint;推送后最多 2 轮 CI,覆盖 300 万+ 测试 |
其中两条设计值得直接抄:
一、混合状态机。 跑 lint、推送代码这种步骤,不需要模型判断,就该走确定性流程;实现功能、修 CI 错误这种需要判断的,才交给 Agent。该死板的地方死板,该灵活的地方灵活——这正是 Agent 栈 里工作流与 Agent 的老问题,Stripe 给出了工程答案。
def orchestrate(task):
plan = agent_plan(task) # Agent:需要判断
lint_result = run_lint(plan.files) # 确定性:不需要判断
if not lint_result.ok:
lint_result = agent_fix_lint(plan) # Agent:需要判断
tests = run_tests(plan) # 确定性
if not tests.ok:
tests = agent_fix_tests(plan, tests) # Agent:需要判断
push(plan) # 确定性:绝不让模型自己推
return wait_ci(plan, max_rounds=2) # 确定性:最多两轮,不无限重试
二、工具子集。 近 500 个工具全塞给一个 Agent 是灾难——工具太多,模型选错的概率直线上升。给每个角色筛一个子集,工具数量回到十几个。
3.5 钩子:在关键点插一道程序
钩子(hook) = 在 Agent 生命周期的固定位置,由 Harness 执行的程序化动作。
常见钩子位置:
| 钩子 | 时机 | 用途 |
|---|---|---|
| PreToolUse | 工具执行前 | 拦截危险命令、注入额外上下文 |
| PostToolUse | 工具执行后 | 自动格式化、自动 lint 修复 |
| PreCommit | 提交前 | 跑测试、跑检查、拦下不合格提交 |
| SessionStart | 会话开始 | 注入任务状态、加载交接文档 |
钩子是 Harness 里最像「代码」的部分——它不依赖模型的判断,因此可靠。Stripe 的 pre-push hook 能在秒级自动修掉 lint 问题,OpenAI 则把自定义 Linter 做成硬约束,让违规在机械层面就被拦下,而不是指望模型记得别犯。
OpenAI 有句话很直白:
If it cannot be enforced mechanically, agents will deviate. (不能被机械执行的约束,Agent 迟早会偏离。)
这条是 L2/L3 层最实用的结论:凡是能用程序检查的规则,就不要只写在提示词里。
3.6 小结:L2 + L3 的检查表
- 每个工具的粒度接近「一个完整意图」
- 每个工具的参数名和描述里有可照抄的例子
- 每个工具的失败返回都包含下一步该做什么
- 规划阶段与执行阶段的工具权限是分开的
- 能用程序判定的步骤(lint、测试、push)走确定性流程
- 每个角色拿到的工具是筛过的子集,不是全集
- 关键节点(提交前、工具执行前后)挂了钩子
动手改
- 挑你项目里 Agent 最常用的三个工具,把它们的描述重写一遍:加上触发场景、给参数加具体例子,然后对比改前改后模型选工具的准确率
- 把某个工具的失败返回从
Error: xxx改成「失败原因 + 建议下一步」的形式 - 在你的工作流里找一段「其实不需要模型判断」的步骤(比如格式化、跑固定测试),把它从 Agent 的活儿里拿出来,改成确定性脚本
小结
- 工具接口设计直接决定完成率:粒度、参数可推断、结果可读、错误可行动
- 工具描述里要写触发场景,参数描述里要给具体例子
- 工具返回值要能指导下一步;日志写成 grep 友好的单行格式并落到文件
- 编排层:规划与执行分阶段,确定性步骤走确定性流程
- 工具太多会出事——给每个角色筛子集(Stripe:500 个工具分发出子集)
- 钩子是 Harness 里最可靠的部分;能机械执行的约束就别只写在提示词里
下一章:记忆与状态,长任务的中途状态到底该放哪 🐸