工具做到一定规模,手上会攒下几十个命名良好的能力函数:填客户、匹配分类、检索知识库、抓当前会话、提交工单、发一条消息……
它们全都是硬编码调用的——一个快捷键对应一个函数,一条固定流程串起五个函数。要让 LLM 也能用上这些能力,得先有一层统一、可枚举、可编排的抽象。
包一层 wrapper 不难。难的是想清楚这句话意味着什么:
一旦 LLM 能自己决定调什么,坐席只是问了一句话,它就可能去提交了一张工单。
副作用分级
整个设计的核心是一个三值枚举:
/** * 副作用等级 —— 决定 agent / LLM 能否自主调用。 * - read 纯读:抓数据、查历史、检索 → 可自主 * - write 改本地状态:填表单、提交 → 固定流程里可自主,LLM 自主调不行 * - send 对外发出去:给客户发消息 → 永远人确认,LLM 绝不可自主 */export type SideEffect = 'read' | 'write' | 'send';分级依据不是「危险程度」这种模糊感觉,而是两个具体问题:
| 做完之后能撤销吗 | 外部的人会立刻看到吗 | |
|---|---|---|
read | 无需撤销 | 否 |
write | 大多能改、能删 | 延迟可见(提交的单子可以再改) |
send | 撤不回来 | 立刻看到 |
send 之所以要单独一级,不是因为它「更危险」,而是因为它同时满足这两条。一条发错的消息,客户已经读到了,你删掉原消息也改变不了这个事实。
tool 的形状
export interface Tool<I = unknown, O = unknown> { /** 唯一名:`<域>.<能力>`。也是给 LLM 的 function name */ name: string; domain: ToolDomain; /** 给人看的短名(面板 / 日志) */ title: string; /** 给 LLM 看的自然语言描述:说清楚做什么、什么时候该用 */ description: string; inputSchema: JsonSchema; sideEffect: SideEffect; /** 仅在这些页面可用。省略 = 任意页 */ pageTypes?: PageType[]; /** 该功能未授权则注册表不暴露此 tool。省略 = 不 gate */ featureFlag?: keyof FeatureFlags; run(input: I): Promise<ToolResult<O>>;}
export interface ToolResult<O = unknown> { ok: boolean; data?: O; error?: string; /** * ★ send 类 tool 的 run() **永不真发** —— 它只准备 payload、返回 requiresConfirm, * 由上层界面拿给人确认之后,再走真发分支。 */ requiresConfirm?: boolean; confirmPayload?: unknown;}关键在最后那两个字段:send 类 tool 的 run() 里没有发送代码。 它做的事是「把这条消息准备好,然后把决定权交出去」。
这不是约定,是结构——就算有人绕过所有闸门调用了它,也发不出去。
注册表:三道闸
async invoke(name: string, input: unknown, ctx: InvokeCtx): Promise<ToolResult> { const tool = this.tools.get(name); if (!tool) return { ok: false, error: `未知 tool:${name}` };
// 闸 1 + 2:功能是否授权、是否在适用页面 const gate = this.isAvailable(tool, ctx); if (!gate.ok) return { ok: false, error: gate.error };
// 闸 3:send + 自主编排 → 绝不放行,强制回到人确认路径 if (tool.sideEffect === 'send' && ctx.autonomous) { return { ok: false, requiresConfirm: true, error: `${name} 对客户发消息,禁止 LLM 自主发送 —— 须人确认`, }; }
try { return await tool.run(input); } catch (e) { // 任何阶段都返回结构化 error,不向外抛 return { ok: false, error: e instanceof Error ? e.message : String(e) }; }}ctx.autonomous 是整个设计的枢纽:同一个 tool,人按快捷键触发时可以跑,LLM 自主编排时被拒。 区别不在 tool 里,在调用语境里。
三层防护,而不是一道
只有注册表这一道闸是不够的。实际的分层是:
① LLM 根本看不到 send 类 tool。
toFunctionSchemas(ctx: InvokeCtx): FunctionSchema[] { return [...this.tools.values()] .filter((t) => this.isAvailable(t, ctx).ok) .filter((t) => !(ctx.autonomous && t.sideEffect === 'send')) // ★ 不进清单 .map((t) => ({ name: t.name, description: t.description, parameters: t.inputSchema }));}给模型的工具清单里就没有它。这是最有效的一层——模型不会去用它不知道存在的东西。
② 执行前再查一次副作用等级。
出口处白名单再判一次 sideEffect === 'read'。这一层防的是模型幻觉出一个不存在的名字——它可能猜出 ticket.submit 这种看起来很合理的名字,哪怕你从没给过它。
③ 注册表原有的三道闸。
三层里任何一层单独看都有点冗余,合起来的意义是:没有任何一处笔误能让越界发生。
最重要的一条区分
我们的边界写在项目规范里,原话是「录单可以自主提交,对客户发消息永远人确认」。
接 LLM 的时候,这句话差点被误读成「所以 quickTicket.submit 可以给 LLM 自主调」。
不是一回事。
「录单可自主提交」说的是一条写死顺序的固定流程:
打开表单 → 抓取会话 → 调模型生成内容 → 填字段 → 校验 → 提交这条链路的输入确定、输出确定、每一步都能单测、出问题能精确定位到哪一步。它里面的「AI 环节」只负责生成文本,不决定要不要提交。
而 function calling 是模型自己决定调什么。坐席在侧栏问一句「这个客户之前报过类似问题吗」,模型完全可能在查完之后顺手调一下提交——它的推理链里那看起来是个合理的下一步。
所以最终的放开范围是:只放开 read。
write(包括不可逆的提交)不给 LLM 自主调。等工单提交做成真正的 API、再配上「模型提议 → 人确认卡片 → 执行」的交互之后,才谈得上放开。
「一条固定流程可以自主」和「一个 agent 可以自主」,中间隔着的是可预测性。
几个踩坑
function name 不能含点
kb.search、quickTicket.fillCustomer——这套 <域>.<能力> 的命名在内部用得好好的。喂给 OpenAI 兼容网关,直接 400。
OpenAI 规范里 function name 只允许 ^[a-zA-Z0-9_-]{1,64}$,点号不合法。严格校验的网关会直接拒,而且不告诉你是哪个字段错了——你只会拿到一个错误码和一句泛泛的说明。
解法是在出口做一层 wire 映射,注册表内部的命名一个字不动(那套名字已经被快捷键、编排器、调试钩子广泛引用了):
const toWire = (n: string) => n.replace(/\./g, '__');const fromWire = (n: string) => n.replace(/__/g, '.');一般化的教训:内部标识符和协议标识符是两回事,中间要留一层映射。 不留的话,任何一个下游的字符集约束都会逼你做一次全局重命名。
结果必须在 tool 内部裁剪
有个查询接口一页返回 511KB。外层有个「结果超过 4000 字就截断」的保护——但那个保护是按字符数截的,会把 JSON 从中间切断,模型拿到一段语法错误的文本,然后一本正经地解释它。
外层截断只能当兜底。每个 tool 都要对自己的返回值负责:只回模型真正需要的字段,条数设上限,长文本自己摘要。
不要顺手把 gating 加严
把快捷键迁到注册表时,我给这批 tool 都加上了 pageTypes,觉得更严谨。
结果是回归:被包的那些函数本来靠 DOM 自适应——找得到字段就干活,找不到就静默返回。加了 pageTypes 之后,几个页面类型识别不出来的场景(包括某些 iframe 里 detectPage() 返回 null 的情况)直接被拒了,而原来的快捷键在那些场景下是能用的。
wrapper 的 gating 不能比被包的函数更严。 包一层的目的是统一入口,不是顺便收紧规则——那属于行为变更,得单独做、单独验。
语义不是 1:1 的就别迁
有几个快捷键我故意没迁:它们做的是「打开一个面板」「触发一个按钮」,而 tool 的语义是「执行一个动作并返回数据」。硬迁会让返回值变得没有意义,调用方还得特判。
迁移的前提是语义对齐,不是名字看起来像。
给自主编排设限额
模型自主调 tool 要设轮次上限(我们是 4 轮)。没有上限的话,一次推理绕不出来就会一直调下去,烧的是真钱。
附带的好处:一套 tool,三个调用方
做完之后发现,收益不止在 LLM 那边:
- 快捷键走同一套 tool → gating 和日志统一了,不再是每个 handler 各写一遍;
- 固定流程走同一套 tool → 编排器只需要一个
AgentStep[]数组; - 调试:加一个主世界桥,就能在 Console 里直接调任何一个能力。
await __extTools.list() // 现在有哪些能力await __extTools.invoke('kb.search', { query: '发票' }) // 直接跑一个await __extTools.runPlan('inspect', { id: 123 }) // 跑一条固定链最后这一条的价值被严重低估了。在此之前,验证一个能力函数要么写临时代码,要么真的去点一遍界面。现在是一行。
桥的写法见 内容脚本的三个世界——
chrome.*在主世界不可用,所以调试入口必须是个转发代理,所有闸门都留在隔离世界。
完整代码:/showcase/graded-tools —— 三个文件、零依赖,注册表 + 最小编排器 + OpenAI 风格 schema 导出,附 18 项行为测试覆盖的边界。
回头看,tool 层里真正有价值的不是那个 Tool 接口——那东西谁都能写。有价值的是逼着自己给每个能力回答一遍「这个做完能撤销吗、外面的人会立刻看到吗」。
回答完之后,哪些能交给模型、哪些必须留给人,答案是自己浮出来的。