Agent 工具层工程:设计给 LLM 用的接口
工具是 Agent 能力的物理边界。一个 Agent 能做什么,本质上取决于它有哪些工具、这些工具设计得怎么样。
工具设计这件事,在大多数 Agent 系统里是被严重低估的工程问题。常见的实现方式是:把现有的函数或 API 包一层,写个描述,往 Agent 里一扔,能跑就好。到了生产,问题集中爆发——模型频繁选错工具,工具被错误参数调用,错误处理逻辑让 Agent 陷入循环,token 成本居高不下。
这些问题大多数不是模型的问题,而是工具设计的问题。
工具接口的客户是 LLM,不是人
这是工具设计和传统 API 设计最根本的区别。
一个 REST API,客户端是代码——它按照 OpenAPI spec 解析字段,类型不对会报错,逻辑是确定性的。工具的客户端是 LLM——它通过自然语言理解工具的用途,通过推理决定何时调用、传什么参数。
这个区别带来了一个不直觉的结论:工具的 description 比 schema 更重要。
传统 API 里,描述是给人看的文档,契约是机器可读的 schema。工具里,description 本身就是契约——LLM 基于 description 决定要不要调用这个工具、怎么构造参数。Schema 规定了格式,description 决定了语义。
一个工具写 description: "搜索文件" 和写 description: "在当前工作目录中按文件名或内容关键词搜索文件,返回匹配的文件路径列表。不适用于跨仓库搜索或 Git 历史查询",模型的调用行为会截然不同。后者多了 40 个 token,但它明确划定了工具的适用边界,减少了误用。
描述的核心要素:工具做什么、在什么情境下用、不适合处理什么。三者缺一,模型就得靠推断填空,填对是运气,填错是 bug。
参数设计:减少模型的组装负担
工具的参数设计,直接决定了模型的推理难度。
避免让模型组装复合结构。如果一个工具需要一个 JSON 对象作为参数,模型要在推理过程中构造这个对象——字段名、嵌套层级、类型——全靠记忆和推断。出错率远高于传入若干扁平的原始类型字段。
1 | |
枚举值优于开放字符串。当参数只有有限的合法取值时,用枚举约束它。开放字符串意味着模型可以传任何值,你得在运行时处理它;枚举让模型在调用阶段就做出正确选择,并在 schema 层面拒绝无效值。
可选参数要说明缺省行为。模型不会自动理解”不传这个参数时会发生什么”——它需要在 description 里被告知。limit: 返回结果数量上限,不传时默认 20,最大 100 比 limit: int = 20 携带的信息量多得多。
幂等性:工具安全重试的基础
Agent 系统里,工具调用失败后重试是常态——网络超时、限流、模型误判参数……任何一个环节都可能触发重试。
不幂等的工具在重试时会产生副作用。发送通知的工具被重试两次,用户收到两条消息;创建资源的工具被重试,创建了两个重复对象。这些问题在 demo 里几乎不会出现,在生产里是真实的故障。
设计工具时,幂等性应该是默认考量而不是事后补丁。几个具体策略:
读操作默认幂等,无需特殊处理。
写操作设计幂等键。创建资源时接受一个 idempotency_key 参数——相同的 key 表示同一个操作,重复调用返回第一次的结果而不是创建新对象。这个 key 由 Harness 层在任务级别生成,确保每个操作有唯一标识。
状态检查前置。执行写操作之前先查询当前状态:资源是否已存在?操作是否已完成?把这个检查内嵌到工具实现里,而不是依赖模型去判断是否需要重试。
幂等性是工具的工程质量属性,不是 Agent 逻辑的责任。把它下沉到工具层,可以显著降低 Harness 层的复杂度。
错误信息:写给 LLM 看的反馈
工具报错时,错误信息的受众是 LLM,不是人类工程师。这个区别在工程实践里几乎从来没有被认真对待过。
传统错误信息的目标是让开发者能快速定位问题:
1 | |
这对 LLM 没有用。LLM 需要的错误信息是:发生了什么、为什么发生、下一步应该怎么处理。
1 | |
两段信息都在描述”user_id 缺失”,但第二段给了模型足够的上下文来自主恢复——它知道问题原因,知道下一步怎么做。第一段只能让模型猜。
错误分类是另一个工程细节。工具的错误大致可以分成三类:
- 可重试错误:临时性故障(超时、限流、服务暂时不可用)。错误信息里应该明确说明”可重试”,让 Harness 层或 Agent 知道直接重试是安全的。
- 参数错误:调用方式不正确。错误信息里应该说明正确的调用方式,让 Agent 修正后重试。
- 业务逻辑错误:操作在业务规则上不被允许(无权限、资源不存在、状态不对)。错误信息应该说明限制来源,帮助 Agent 决策是否需要走其他路径。
让模型能区分这三类错误,它才能做出正确的后续决策——而不是在不可重试的错误上无限重试,或者在可重试的错误上直接放弃。
输出结构:context 效率的关键
工具的输出直接进入 context 窗口。输出的大小和结构,直接影响 context 的利用率和成本。
返回 Agent 需要的,不是工具能返回的全部。一个 API 调用可能返回几十个字段,但 Agent 在当前任务里只需要其中三个。在工具层做字段筛选,比让 Agent 自己从大量无关字段里提取信息效率更高——节省了 context 空间,也降低了模型的注意力负担。
这里有一个需要权衡的地方:字段筛选会降低工具的通用性。一个”查询用户信息”的工具,如果为了节省 token 只返回 name 和 email,那在需要 role 字段的任务里就得重新调用。
折中方案是按任务类型动态筛选:工具接受一个 fields 参数,默认返回最常用的核心字段集,调用方可以显式请求额外字段。这把筛选决策权交给了使用工具的上层逻辑,工具本身保持通用性,context 效率由调用方根据任务需要自行控制。
大体量输出的分页处理。一次工具调用返回 200 条记录,把它们全塞进 context 是个反模式——大多数情况下 Agent 只需要前几条,或者需要按某个条件过滤。工具应该默认做分页,用合理的默认 limit(20-50 条),并在返回结果里包含总数和是否有更多数据的标志,让 Agent 决定是否需要继续翻页。
工具的可测试性
Context Engineering 那篇提到过,工具单元测试是 Agent 评估体系的第一层。但”可测试性”不只是”能被测试”,而是工具在设计上支持高质量的测试。
纯函数优先。工具的核心逻辑应该是纯函数:给定相同的输入,返回相同的输出,没有隐式的外部依赖。副作用(写数据库、发请求)封装在边界层,核心逻辑保持确定性。这让单元测试能在不依赖外部系统的情况下覆盖主路径。
可注入的依赖。工具的外部依赖(数据库连接、HTTP 客户端、文件系统)应该通过依赖注入而不是全局引用传入。测试时用 mock 替换真实依赖,能在隔离环境里验证工具的行为边界。
边界场景的显式测试。除了正常路径,工具测试应该覆盖:空输入、类型边界、权限不足、依赖服务失败。这些场景在 demo 里不常见,在生产里每周都会遇到。测试套件里如果只有 happy path,它给的安全感是虚假的。
LLM 可用性测试。一个容易被忽视的维度:工具在功能上正确,但 description 写得不好,模型实际调用的效果很差——参数老是传错,或者在不该调用的时机调用。可以在评估集里加入”工具选择准确率”这个指标:给定一批任务,Agent 选择的工具序列是否符合预期?这个指标的退步,往往是工具 description 被改坏的信号。
工具的版本演化
Agent 系统上线之后,工具会随着需求不断演化——加字段、改行为、拆合工具。这个过程有几个坑。
工具重命名或接口变更,需要同步更新 description。模型对工具的”理解”来自 description,不是函数签名。函数改了,description 没更新,模型对工具的认知和实际行为之间就有了偏差。
行为变更要考虑对历史任务的影响。如果工具的语义发生了实质性变化,依赖这个工具的历史 prompt 和评估集可能都需要更新。工具变更不只是代码变更,它是一次接口升级,受影响范围需要系统梳理。
拆分工具时做好过渡期。把一个大工具拆成两个更专一的工具,是工具设计演化中常见的重构。如果直接删除旧工具,所有依赖它的 Agent 行为都会受影响。更稳妥的方式是保留旧工具一段时间,让它在内部调用新工具,同时更新 description 引导模型逐步迁移到新工具。
工具层是 Agent 系统里最容易被当作”胶水代码”对待的部分,但它实际上是 Agent 与外部世界之间的全部接触面。
接触面的设计质量,决定了 Agent 在这个接触面上的行为可靠性。把工具设计当作一门独立的工程学科来对待,和 API 设计、数据库 schema 设计同等重视——这是 Agent 系统从 demo 走向生产的一个基础条件,往往也是被跳过的那个。