本文撰写于 2026 年 3 月,其中的部分内容可能很快过时。
本文编写过程中有 Qwen 3.5 Plus 的辅助。
最近看到了一些关于 Agent 工具设计的新思路,记录一下。
回顾
我们先回顾一下目前主流的工具调用是什么样的。
现在的工具调用倾向于为每个功能封装一个工具,这些工具可能有内部的代码实现或者接入 API。然后,由 Agent 框架(或者由我们手动)将这些工具一股脑发给 LLM,LLM 从中选择一个(或若干个,如果它支持并行工具调用)工具进行调用,工具调用的输出将作为一条 ToolMessage 返回给模型,然后模型再决定是调用下一个工具,还是向用户报告结果。
这种方式有什么问题?我们用一个例子来看一下。这是由 Qwen 生成的一个例子,可能有些生硬,但应该能够说明问题。
我们希望查询过去 30 天的用户活跃数据,筛选出日活跃用户(DAU)>1000 的地区,计算这些地区的平均 7 日留存率,并生成简要分析报告。
我们会有这些工具:
query_analytics(start_date, end_date, metrics, group_by):查询原始数据filter_records(data, condition):按条件过滤数据calculate_metrics(data, metric_type):计算统计指标generate_report(summary, format):生成格式化报告
第一轮:
┌─────────────────────────────────────┐
│ Round 1: 模型请求查询原始数据 │
├─────────────────────────────────────┤
│ Model → [tool_call: query_analytics]│
│ {start: "2026-02-10", │
│ end: "2026-03-12", │
│ metrics: ["dau","retention"],│
│ group_by: "region"} │
│ │
│ System → 执行工具 → 返回原始数据 │
│ (假设返回 50 个地区 × 30 天 = 1500 条记录)│
└─────────────────────────────────────┘
第一轮结束后,1500 条记录进入模型的上下文。
第二轮:
┌─────────────────────────────────────┐
│ Round 2: 模型请求过滤数据 │
├─────────────────────────────────────┤
│ Model → [tool_call: filter_records] │
│ {data: <1500条记录引用>, │
│ condition: "dau > 1000"} │
│ │
│ System → 执行 → 返回过滤后300条记录 │
└─────────────────────────────────────┘
第二轮结束后,300 条记录进入模型的上下文。
第三轮:
┌─────────────────────────────────────┐
│ Round 3: 模型请求计算留存率均值 │
├─────────────────────────────────────┤
│ Model → [tool_call: calculate_metrics]│
│ {data: <300条记录引用>, │
│ metric_type: "avg_retention"}│
│ │
│ System → 执行 → 返回 {"avg_retention": 0.342}│
└─────────────────────────────────────┘
第四轮:
┌─────────────────────────────────────┐
│ Round 4: 模型生成最终报告 │
├─────────────────────────────────────┤
│ Model → [tool_call: generate_report]│
│ {summary: {...}, format: "markdown"}│
│ System → 返回报告文本 │
│ Model → 输出最终回答给用户 │
└─────────────────────────────────────┘
我们可以看到一些明显的问题:
- 我们需要在本地运行时和远程的 LLM Provider 之间进行 4 轮网络调用,这浪费了一些时间。
- 第一轮和第二轮产生的 1500+300 条数据持续存在于上下文中,后续的所有请求都要完整携带这些数据
Codemode
我们发现,目前的工具调用方式是由模型直接调用的:模型指定调用哪个工具,本地运行时调用对应的工具,然后把结果返回给模型。
Codemode,或者说编程式工具调用(Programmatic Tool Calling)不再让模型直接调用工具,而是让它写一段代码来调用工具。这是符合模型直觉的:因为目前所有的主流模型都在数百万个开源代码仓库上训练,具有强悍的编程能力;而比起编写代码,模型受到的工具调用训练可能就少得多:人类世界不需要工具调用,所以互联网上爬不到这种数据,工具调用的训练数据很可能是模型团队为了训练而合成出来的,数量上就没有优势。
为了实现 Codemode,我们需要将工具封装为对应语言(如 Python 或 TypeScript)的函数,然后模型编写这种语言的代码去调用工具。为了运行工具,我们需要一个运行时来执行代码。由于工具调用通常很频繁而且用时较短,我们期望这个运行时能够快速启动并且节省资源,在工具调用完成后我们可以立即丢弃运行时,并在下次调用时开一个新的。
现在我们看一下在 Codemode 中,上面的例子是如何完成的。
模型仍然能看到所有工具的接口,但我们禁止它直接调用这些工具。它将被要求写一段代码来调用这些工具:
# 工具已通过 bindings 自动注入,无需导入
async def generate_active_region_report():
# 1. 查询原始数据(仅一次工具调用)
raw_data = await query_analytics(
start_date="2026-02-10",
end_date="2026-03-12",
metrics=["dau", "retention_7d"],
group_by="region"
)
# 2. 在沙盒内完成数据处理(不调用外部工具)
high_activity_regions = [
record for record in raw_data
if record["dau"] > 1000
]
if not high_activity_regions:
return "⚠️ 无符合条件的地区"
# 3. 计算聚合指标(纯代码逻辑)
retention_values = [r["retention_7d"] for r in high_activity_regions]
avg_retention = sum(retention_values) / len(retention_values)
region_count = len(set(r["region"] for r in high_activity_regions))
# 4. 仅最终结果调用报告工具
summary = {
"period": "2026-02-10 ~ 2026-03-12",
"qualified_regions": region_count,
"avg_7d_retention": round(avg_retention, 3),
"insight": "高活跃地区留存表现" + ("优秀" if avg_retention > 0.3 else "待提升")
}
report = await generate_report(summary, format="markdown")
return report
# 执行入口
result = await generate_active_region_report()
print(result)
上述代码将被提交到轻量级运行时进行执行,我们和模型的第一轮(如果不出错,那么也是唯一一轮)交互:
┌─────────────────────────────────────┐
│ 单次请求:模型输出上述代码 │
├─────────────────────────────────────┤
│ System → 在沙盒中执行代码: │
│ • query_analytics() → 获取1500条 │
│ • 列表推导式过滤 → 内存中处理 │
│ • 纯Python计算均值 → 无工具调用 │
│ • generate_report() → 生成最终文本│
│ │
│ System → 仅返回最终报告文本给模型 │
│ Model → 输出最终回答 │
└─────────────────────────────────────┘
对比前面的问题:
- 本地运行时和远程的 LLM Provider 之间的多轮交互将被一轮交互替代:模型写代码 → 本地执行 → 结果返回给模型
- 所有中间结果不会污染上下文,仅返回最终的结果给模型。这不牺牲灵活性,如果模型想看中间结果,那它可以写代码输出中间结果。
我们对比一下两种方式:
| 阶段 | 传统工具调用 | 编程式工具调用 |
|---|---|---|
| 1. 模型输出 | 特殊token + JSON格式的工具调用 | 可执行的代码片段(Python/TypeScript) |
| 2. 执行环境 | 外部程序解析并调用工具 | 沙盒容器执行代码,代码内调用工具 |
| 3. 结果传递 | 每次工具结果都返回模型上下文 | 中间结果在沙盒内处理,仅最终结果返回模型 |
| 4. 多工具协作 | 每步调用需模型重新采样 | 单次代码执行可完成多步工具调用 |
相对于传统方式,Codemode 的优势:
- 显著降低 Token 消耗
- 传统方式:每次工具调用的输入/输出都计入上下文,10 次调用≈10 倍 token
- 编程式:仅最终聚合结果进入上下文,中间数据在沙盒内处理
- 大幅减少延迟
- 消除“模型采样 → 工具调用 → 结果返回 → 再次采样”的串行等待
- 多个工具调用可在单次代码执行中并行或流水线完成
- 示例:查询 5 个区域数据,传统需 5 轮往返,编程式仅需 1 轮
- 提升模型处理复杂工具的能力
- 模型在训练数据中见过海量真实代码,但工具调用格式是人工构造的
- 将工具暴露为函数接口后,模型可借助代码推理能力自然组合操作
- 支持更复杂的控制流:循环、条件判断、异常处理等
目前业界已经在积极探索这种新的工具调用模式:
- Pydantic 团队用 Rust 写的轻量级 Python 运行时:https://github.com/pydantic/monty
- Cloudflare 对 Codemode 的介绍:https://blog.cloudflare.com/code-mode/
- Claude 的编程式工具调用文档:https://platform.claude.com/docs/zh-CN/agents-and-tools/tool-use/programmatic-tool-calling
Unix 命令行风格的工具
来源于在 Reddit 上看到的一个帖子:https://www.reddit.com/r/LocalLLaMA/comments/1rrisqn/i_was_backend_lead_at_manus_after_building_agents/ 作者是 Manus 的后端 leader。
与代码类似,LLM 对命令行(特别是 *nix 命令行)操作极其熟悉,因此,我们将工具设计为 *nix 命令行风格有望显著提升工具调用的效果。
作者提出以一个名为 run 的单一工具聚合所有工具,这个 run 工具将向模型提供一个高度类似 *nix 命令行的交互:
run(command="cat notes.md")
run(command="cat log.txt | grep ERROR | wc -l")
run(command="see screenshot.png")
run(command="memory search 'deployment issue'")
run(command="clip sandbox bash 'python3 analyze.py'")
善用管道
*nix 命令行的精髓之一是使用管道来组合命令实现非常复杂的功能。run 工具也提供这种功能:
| 管道:前一个命令的 stdout 将成为后一个命令的 stdin
&& 与:当前一个工具执行成功时,执行后一个工具
|| 或:无论前一个工具是否成功,都执行后一个工具
; 序列:按照顺序执行,无论是否成功
使用管道的例子:
# One tool call: download → inspect
curl -sL $URL -o data.csv && cat data.csv | head 5
# One tool call: read → filter → sort → top 10
cat access.log | grep "500" | sort | head 10
# One tool call: try A, fall back to B
cat config.yaml || echo "config not found, using defaults"
启发式设计
一个好的命令行工具不需要使用 man 看大量的手册,而是用 --help 来查看帮助。
这里我们介绍一下逐渐披露(progressive disclosure),这是一个由 Agent Skills(https://agentskills.io/home )带起来的范式,模型不在一开始就获取有关某物(工具,或者对某种能力的说明)的全部信息,而是在一开始获得一个列表,告诉它“你能使用这些东西“;如果模型需要使用某个工具,那它主动选择加载关于目标工具的更多信息。这种范式节省了宝贵的上下文窗口,而且能够避免模型在对话前期的大量文档数据中迷失。
相比一次性获取所有工具的说明,基于命令行 help 的文档能够实现逐渐披露。例如 run 工具在对话开始时会输出所有子工具的列表,每个子工具有一句简短的说明:
Available commands:
cat — Read a text file. For images use 'see'. For binary use 'cat -b'.
see — View an image (auto-attaches to vision)
ls — List files in current topic
write — Write file. Usage: write <path> [content] or stdin
grep — Filter lines matching a pattern (supports -i, -v, -c)
memory — Search or manage memory
clip — Operate external environments (sandboxes, services)
...
而如果模型 run 某个子工具,却没有指定任何参数,那么它会得到关于该工具的更详细文档:
→ run(command="memory")
[error] memory: usage: memory search|recent|store|facts|forget
→ run(command="clip")
clip list — list available clips
clip <name> — show clip details and commands
clip <name> <command> [args...] — invoke a command
clip <name> pull <remote-path> [name] — pull file from clip to local
clip <name> push <local-path> <remote> — push local file to clip
类似地,当 run 某个子工具并且指定了参数,但没有指定剩余参数时,模型将获得关于对应参数的详细文档。
→ run(command="memory search")
[error] memory: usage: memory search <query> [-t topic_id] [-k keyword]
→ run(command="clip sandbox")
Clip: sandbox
Commands:
clip sandbox bash <script>
clip sandbox read <path>
clip sandbox write <path>
File transfer:
clip sandbox pull <remote-path> [local-name]
clip sandbox push <local-path> <remote-path>
当然,这要求我们在设计工具时必须编写详细的文档。
在错误中提示正确的方法
Agent 犯错是很正常的。优秀的工具设计会将 Agent 向正确的方向引导。
我们可以要求每个错误信息都输出“为什么错“以及“如何改正“两部分内容:
传统 CLI:
$ cat photo.png
cat: binary file (standard output)
→ 然后人类用户上网搜索 "如何在终端中查看图片"
针对 Agent 的工具:
[error] cat: binary image file (182KB). Use: see photo.png
→ 然后 Agent 直接调用 see 工具
stderr
stderr 很重要。如果我们不显式配置,那么 stderr 很可能会被丢弃并且无法被 Agent 看到,这会导致它执行大量的尝试来匹配正确的方法。我们应该配置 stderr 让 Agent 看到,这样调用就更有针对性。
元数据
在工具的执行结果中输出一些元数据,例如 [exit:0 | 12ms],有助于 Agent 了解执行结果以及耗时。
退出代码遵循约定俗成的方式,例如 0 表示一切正常,1 表示通用错误,而 127 表示命令未找到。
执行时间则有助于 Agent 评估某个工具调用的时间成本。如果它发现工具执行时间很长,那么它可能倾向于不要频繁调用这个工具。
截断
截断同样重要。有些命令会产生超长的输出,人类用户可以滚动终端并且选择他们感兴趣的部分阅读,但 LLM 不行,如果不截断,工具的所有输出都会被同等地注意到。
二进制数据
命令行风格的工具必须考虑到二进制输出。二进制对于 LLM 来说没有任何意义,只会污染上下文。因此在工具返回任何内容之前,我们需要先检查它是否为二进制内容:
- 包含空字节
- UTF-8 验证失败
- 控制字符 > 10%
检测到二进制内容时,应该直接告诉模型这是二进制,要求它使用另一种方式来查看。