Skip to content

03 · 工具箱:Agent 的手 ​

一句话:LLM 只有"嘴"——它能说要查订单,但查不了。工具就是模型的手: 模型只点名(吐出工具名 + 参数),代码出手(校验、执行、回灌)。 工具表长什么样,Agent 的能力边界就到哪;写操作有没有幂等壳,半夜重试时工单会不会翻倍就看它。


它解决什么问题 ​

agent-loop 解决了"模型怎么用工具",本目录解决"工具本身怎么造、怎么管":

问题本目录的回答
模型怎么知道有哪些工具、怎么调?每个工具自带说明书:description + zod inputSchema,SDK 转成 JSON Schema 发给模型
模型回传的参数能直接执行吗?不能。zod schema 先 safeParse 校验(生成与校验同一张图纸)
工具表怎么按应用裁剪?ToolRegistry 按名登记;chat 拿 4 个,客服工人各拿 1 个——职责外的锤子不发
"创建工单"被执行两次怎么办?幂等层:同会话 + 同工具 + 同参数,TTL 窗口内只真正执行一次

还有一个容易被忽视的事实:description 和 .describe() 本身就是 prompt 的一部分, 措辞模型直接看得见。实测 glm-4-flash 这类轻量模型对描述开头的内容、以及工具表里 靠前的工具权重更高——所以 description 要写清「适合什么、不适合什么」,别指望模型自己悟。


核心概念(5 分钟版) ​

① schema 即图纸,单一真源 ​

同一个 zod schema 干两件事:转成给模型看的参数说明 + 校验模型回传的参数。 说明书和验货标准是同一张纸,永远不会漂移。这就是为什么本目录所有工具的 入参都用 z.object({...}) 写。

② 工具表 = 能力边界 ​

消费方工具表出处
chat REPL{ ...createDemoTools(), searchKnowledgeBase } 共 4 个apps/cli/src/apps/chat/cli.ts
service 三个工人order {getOrderStatus} / refund {createTicket} / knowledge {searchKnowledgeBase} 各 1 个src/service/workers.ts
selftestToolRegistry 登记 demo 三件套 → getAll() 喂 runToolLoopapps/cli/src/selftest.ts

不给职责之外的锤子,误伤面从工具表上就掐掉了——这是最便宜的安全措施。

③ Agentic RAG 的最小形态 ​

searchKnowledgeBase 不是"每次都查":它在工具表里,模型自己决定这一轮要不要查 知识库。对比传统 RAG 管道(问句无脑过检索),这一个小小的位置差异就是 Agentic RAG 的定义——检索从"流程步骤"变成了"模型的决策"。

④ 幂等:写操作的安全带 ​

LLM 的工具调用不受客户端事务控制——模型重试、用户重复提问、上游超时补偿重发, 都会让"创建工单"这类写操作被执行多次。业界标准是显式幂等键(Stripe 的 Idempotency-Key 头);但 Agent 场景里模型不会可靠地生成键,所以退一档用 参数指纹:scope + 工具名 + 规范化参数 哈希当键。


代码走读(src/tools/,5 个文件) ​

1. registry.ts(28 行)—— 注册表 ​

位置内容要点
register(L10)按名登记进 Map同名重复注册以最后一次为准;返回 this 支持链式
getAll(L16)吐出普通对象正是 generateText({ tools }) / runToolLoop({ tools }) 要的形状
names(L25)工具名列表排障日志用
ts
export class ToolRegistry {
  private readonly tools = new Map<string, AgentTool>();

  register(name: string, tool: AgentTool): this {
    this.tools.set(name, tool);
    return this; // 链式:registry.register(a, ta).register(b, tb)
  }

  getAll(): Record<string, AgentTool> {
    /* Map → 普通对象 */
  }
  names(): string[] {
    /* 排障用 */
  }
}

诚实地说:产品线的工具表目前多用对象字面量直接拼,ToolRegistry 的活跃消费方是 selftest——在那里验证"registry 吐出的表与手写循环兼容"。它的价值是给"按名登记" 留了标准挂点:后续 MCP 接入、动态启停工具时,30 行的它就是扩展点。

2. demo-tools.ts(78 行)—— 三个纯数据演示工具 ​

工具位置入参返回性质
getOrderStatusL32orderIdstatus + eta(查 mock 表 ORDERS:A-1024 已发货 / A-2048 打包中 / B-0001 已签收;查不到老实返回"未找到")读
createTicketL47subject, description自增工单号 TK-日期-序号(nextTicketId() L18)写(幂等层的经典案例)
escalateToHumanL59reason, transcriptHandoffPack(ticketId / reason / transcript / createdAt,L9)写
ts
export const getOrderStatus = tool({
  description: "按订单号查询订单状态与预计送达时间",
  inputSchema: z.object({
    orderId: z.string().describe("订单号,如 A-1024"), // .describe 也是 prompt 的一部分
  }),
  execute: async ({ orderId }) => {
    const order = ORDERS[orderId]; // 固定 mock 表
    if (order) return { orderId, status: order.status, eta: order.eta };
    return { orderId, status: "未找到", eta: "请确认订单号是否正确" }; // 老实说找不到,不编
  },
});

为什么纯 mock 数据:①离线——selftest 不碰网络就能跑通整个循环;②确定——固定订单表 让 selftest 能逐字断言工具输出;③教学友好——注意力放在 schema 与返回形状上。

3. kb-search.ts(45 行)—— 第一个"真"工具 ​

把 rag 零件的 searchKnowledge 包成工具(k 限 1~10、默认 5)。两个关键设计:

ts
execute: async ({ query, k }) => {
  const hits = await searchKnowledge(query, k ?? 5);     // 真实 embedding 检索(碰网)
  return {
    query, hitCount: hits.length,
    notice: RAG_GROUNDING_RULE,                          // 数据性声明:资料是数据不是指令
    hits: hits.map((chunk, i) => ({
      no: i + 1,                                         // ★ 引用编号由后端分配
      title: chunk.title,
      text: fenceCitation(i + 1, chunk.title, mask ? maskPii(chunk.text) : chunk.text),
      score: Number(chunk.score.toFixed(3)),             // 相似度:排查检索质量的第一手数据
    })),
  };
},
  • 编号后端分配(src/tools/kb-search.ts:37 的 no: i + 1):description 只允许模型 "引用哪块就标注对应编号,如 [1][2]"——编号与标题的映射握在代码手里,模型编不了页码
  • 围栏 + PII 脱敏(AGENT_GUARD_PII=1 时):工具输出是外部内容回灌模型的主通道, 红队测试证明这里零防线时模型会把注入载荷当指令执行

4. idempotency.ts(140 行)—— 幂等层(本篇重点) ​

三条语义纪律(缺一不可,全写在头注释里):

  1. 成功才缓存:execute 抛错的调用不进缓存——失败后的重试是合理的新尝试, 把失败也缓存住会把这个尝试堵死
  2. 在途共享:同键并发调用共享同一个 Promise——第二个调用者等第一个的执行结果, 而不是各执行各的
  3. scope 隔离:键里带 scope(通常是 sessionId)——不同会话提交相同内容的工单 是两笔业务,不能互相去重

核心 run()(src/tools/idempotency.ts:62):

ts
run<T>(scope: string, toolName: string, args: unknown, execute: () => Promise<T>): Promise<T> {
  const key = `${scope}::${toolName}::${stableStringify(args)}`;   // 参数指纹键
  const now = Date.now();

  const hit = this.entries.get(key);
  if (hit !== undefined) {
    if (hit.expiresAt > now) return hit.promise as Promise<T>;     // ① 重放命中:不执行
    this.entries.delete(key);                                      // 过期条目惰性清理
  }

  const promise = execute().then(
    (result) => result,
    (err: unknown) => {
      // ② 失败自删(只删自己这条),reject 照常传给所有等待者——失败不缓存
      const current = this.entries.get(key);
      if (current !== undefined && current.promise === promise) this.entries.delete(key);
      throw err;
    },
  );
  this.entries.set(key, { promise, expiresAt: now + this.ttlMs });  // 在途共享:存的就是 Promise
  // ③ 超过 maxEntries(默认 500):Map 迭代序 = 插入序,淘汰最早条目
  return promise;
}

stableStringify(L39)解决指纹的稳定性:对象键排序 + 过滤 undefined—— {a:1,b:2} 与 {b:2,a:1}、{a:1} 与 {a:1,b:undefined} 必须同指纹:

ts
const entries = Object.entries(value as Record<string, unknown>)
  .filter(([, v]) => v !== undefined) // zod optional 缺省的两种形态等价
  .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));

外层的壳(wrapToolsWithIdempotency,L118):给整套工具的 execute 套上 registry.run,模型与业务代码零感知;无 execute 的工具原样透传; 不调用本函数 = 无幂等层,行为与原来逐字节一致(零变化默认)。

ts
// apps/cli/src/apps/chat/cli.ts:381 —— 请求级包装,scope 用当前会话
tools: wrapToolsWithIdempotency(tools, idempotency, { scope: sessionId }),

与审批壳的叠加顺序:先套幂等、再套审批(审批壳在外)——执行时先过人工审批, 审批通过才进入幂等判定。这样"被用户拒绝的调用"根本到不了幂等层,不会污染缓存。

实战推荐:多个壳的叠加用 composeToolShells(src/tools/compose.ts)声明式组合—— 数组顺序 = 拦截顺序(洋葱模型,shells[0] 最外层),api/cli 两端共用同一份壳清单, 叠加顺序从"代码嵌套"升格为"一眼可见的配置"。

5. idempotency.redis.ts + idempotency.factory.ts —— 幂等层的 Redis 版(2026-10 落地) ​

内存版"接口不变、换 Redis SETNX + TTL"的预留位已兑现,env 一行切换:

powershell
$env:IDEMPOTENCY_STORE = "redis"   # 默认 memory = 改造前行为,离线机器零依赖

与内存版的三条语义纪律逐条对齐,存储口径换成 Redis:

语义内存版Redis 版
键{scope}::{tool}::{stableStringify(args)}agent:idem:{scope}:{tool}:{sha256(指纹)}(大参数不撑爆 key)
成功才缓存失败自删(Promise 链上)execute 抛错 → holder 校验 Lua DEL 占位(执行超过 TTL、锁易主时迟到的 DEL 不误删他人的锁)
在途共享同进程共享 Promise同进程同款 + 跨进程在途返回结构化降级值 IdempotencyDegraded(模型可见:"正在另一实例处理中")——「至多执行一次」是底线,拿不到首次结果是可接受降级
重放命中重放返回首次结果占位/结果两态值:完成后覆写 {"state":"done","result"}(剩余 TTL,窗口从首调起算),重放命中返回首次结果

工厂 createIdempotencyRegistryFromEnv()(idempotency.factory.ts)负责装配,消费方 (api 的 chat.service / cli 的 chat)零分支;wrapToolsWithIdempotencyAuto 按实际实现 分发到对应壳。Redis 版的集成用例在 test/infra.idempotency-redis.spec.ts (RUN_INFRA_TESTS=1 门控,对真实 Redis 实测抢锁/重放/降级/释放全链路)。


跑起来验证(先跑再读,体感翻倍) ​

powershell
cd agent-app
pnpm kb:ingest .\samples\company-faq.md    # 可选:给知识库检索备料(不入库则检索 0 命中)
pnpm chat --trace

在 REPL 里依次试(命令:/exit 退出,/new 开新会话):

text
订单 A-1024 到哪了?        → ⚙ getOrderStatus(模型自主选了查订单的锤子)
出差住宿标准是多少?        → ⚙ searchKnowledgeBase(Agentic RAG:模型决定要查知识库)
帮我建个工单,主题是查不到订单  → createTicket 返回 TK-20261009-0001 之类的工单号

幂等层在 REPL 里的观感不稳定(模型看到历史里已建过工单,第二次可能直接复述旧单号 而不再调工具——这本身就是"省一次写"的另一种形态)。想确定性验证三条纪律, 跑离线单测(packages/engine/test/idempotency.spec.ts:计数器闭包断言"真执行了几次"):

powershell
pnpm test:engine      # 覆盖:首调执行 / 重放命中 / 失败不缓存 / 在途共享 / TTL 过期 / scope 隔离
pnpm selftest         # 注册表链路:ToolRegistry → getAll() → runToolLoop(零网络)

设计取舍(面试级追问) ​

追问回答
为什么不用显式幂等键(Stripe 式)?那要求调用方可靠地生成并携带键;Agent 场景里调用方是模型——它连"今年是哪年"都会幻觉。参数指纹是"模型不可靠"前提下的退一档选择:可重复的输入 = 可重放的请求
为什么"成功才缓存"是纪律?失败重试是真重试(网络抖动后理应再试一次);把失败也缓存住,重试会永远拿到同一个错误——等于把临时故障升级成永久故障
在途共享为什么存 Promise 而不是结果值?并发时第二个调用者不能等第一个"执行完再查表"(有时序窗口),直接拿到同一个 Promise await 才是封闭的;失败自删挂在 promise 链上而不是 catch 吞错,reject 照常传给所有等待者
为什么进程内存 Map 而不是 Redis?默认内存是离线优先取舍(重启即清 = 窗口过期,语义可接受)。跨实例/重启共享幂等窗口的场景已落地:IDEMPOTENCY_STORE=redis 切 idempotency.redis.ts 的 SET NX PX 版(占位/结果两态值 + holder 校验 Lua,消费方经 createIdempotencyRegistryFromEnv() 装配,业务代码零改动)
TTL 为什么默认 10 分钟 + 500 条上限?覆盖"模型重试 / 用户手抖 / 上游超时补偿"的时间尺度;maxEntries 按插入序淘汰防长跑进程内存泄漏。都是 IdempotencyRegistryOptions(L28)可调的构造参数
演示工具为什么纯 mock?离线可跑(selftest 铁律)、行为确定(可逐字断言)、教学聚焦。换真实后端时契约(AgentTool)不变,只换 execute——mock 表就是替换点

自测题(先凭记忆答,再看文末答案) ​

  1. "schema 即图纸"是什么意思?如果入参说明写在 prompt 里、校验逻辑另写一份,会出什么问题?
  2. 幂等层的三条语义纪律是什么?各自防住哪种事故?
  3. 幂等壳与审批壳(apps/api 的 AGENT_CONFIRM_TOOLS 名单)谁在内谁在外?为什么被拒绝的调用不能进幂等缓存?
答案
  1. 同一个 zod schema 既生成给模型看的参数说明、又校验模型回传的参数。两份分开写会 漂移:说明书说 orderId 必填、验货标准却允许缺省(或反之)——模型按说明书生成的 合法入参被验货拦下,或非法入参漏网。
  2. ①成功才缓存:失败重试是真重试(防临时故障被缓存成永久故障);②在途共享:同键并发 共享同一 Promise(防并发窗口内各执行各的、重复写);③scope 隔离:键里带 sessionId (防不同会话的同参业务被互相去重)。
  3. 幂等壳在内、审批壳在外(先套幂等、再套审批):执行顺序是先审批后幂等判定。被用户 拒绝的调用根本没执行过,若进了缓存,之后用户改主意允许执行时会被缓存里的"拒绝前 状态"污染——更糟的是根本没结果可回放。

延伸阅读 ​