← 返回文章列表

ARTICLE /

浅谈 LLM Agent 工具设计的一些新范式

最后更新于 2026-03-12

本文撰写于 2026 年 3 月,其中的部分内容可能很快过时。

本文编写过程中有 Qwen 3.5 Plus 的辅助。

最近看到了一些关于 Agent 工具设计的新思路,记录一下。

回顾

我们先回顾一下目前主流的工具调用是什么样的。

现在的工具调用倾向于为每个功能封装一个工具,这些工具可能有内部的代码实现或者接入 API。然后,由 Agent 框架(或者由我们手动)将这些工具一股脑发给 LLM,LLM 从中选择一个(或若干个,如果它支持并行工具调用)工具进行调用,工具调用的输出将作为一条 ToolMessage 返回给模型,然后模型再决定是调用下一个工具,还是向用户报告结果。

这种方式有什么问题?我们用一个例子来看一下。这是由 Qwen 生成的一个例子,可能有些生硬,但应该能够说明问题。

我们希望查询过去 30 天的用户活跃数据,筛选出日活跃用户(DAU)>1000 的地区,计算这些地区的平均 7 日留存率,并生成简要分析报告。

我们会有这些工具:

第一轮:

┌─────────────────────────────────────┐
│ 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 → 输出最终回答给用户           │
└─────────────────────────────────────┘

我们可以看到一些明显的问题:

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 → 输出最终回答                 │
└─────────────────────────────────────┘

对比前面的问题:

我们对比一下两种方式:

阶段 传统工具调用 编程式工具调用
1. 模型输出 特殊token + JSON格式的工具调用 可执行的代码片段(Python/TypeScript)
2. 执行环境 外部程序解析并调用工具 沙盒容器执行代码,代码内调用工具
3. 结果传递 每次工具结果都返回模型上下文 中间结果在沙盒内处理,仅最终结果返回模型
4. 多工具协作 每步调用需模型重新采样 单次代码执行可完成多步工具调用

相对于传统方式,Codemode 的优势:

  1. 显著降低 Token 消耗
    • 传统方式:每次工具调用的输入/输出都计入上下文,10 次调用≈10 倍 token
    • 编程式:仅最终聚合结果进入上下文,中间数据在沙盒内处理
  2. 大幅减少延迟
    • 消除“模型采样 → 工具调用 → 结果返回 → 再次采样”的串行等待
    • 多个工具调用可在单次代码执行中并行或流水线完成
    • 示例:查询 5 个区域数据,传统需 5 轮往返,编程式仅需 1 轮
  3. 提升模型处理复杂工具的能力
    • 模型在训练数据中见过海量真实代码,但工具调用格式是人工构造的
    • 将工具暴露为函数接口后,模型可借助代码推理能力自然组合操作
    • 支持更复杂的控制流:循环、条件判断、异常处理等

目前业界已经在积极探索这种新的工具调用模式:

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 来说没有任何意义,只会污染上下文。因此在工具返回任何内容之前,我们需要先检查它是否为二进制内容:

检测到二进制内容时,应该直接告诉模型这是二进制,要求它使用另一种方式来查看。

—

感谢阅读,愿你今天也有新的发现。