AI Agent··约 7 分钟·2,789

Agent 工具设计的几个反直觉结论

写 DSH 插件时踩出来的一组反直觉经验:注册成功不代表模型能调用、依赖顺序不能靠运气、子进程输出要自己收、以及为什么工具描述比参数更重要。

我给 DSH 写过几个插件:一个设备控制、一个上下文压力守卫、一个费用统计面板。工具加起来不到二十个,踩的坑却挺集中——而且大多是"我以为会这样,实际是那样"的类型。

这篇不讲怎么注册工具,讲那些我改了两三次才接受的事实。例子统一来自那个设备控制插件(下文叫 device-control):它把一类外部设备的操作包成一组工具,另外带一个 client 面板,形态够杂,坑也就踩得齐。

一、注册成功不等于模型能调用

我第一次注册工具时写的是"作者友好格式":参数写成 { device: 'string', power: 'boolean' } 这样的映射表。加载没报错,日志干净,插件列表里也在。然后模型调用时失败。

原因是工具的 parameters 必须是完整 JSON Schema

parameters: {
  type: 'object',
  properties: {
    device: { type: 'string', description: '受控设备名称(支持模糊匹配)或 device_id' },
    power:  { type: 'boolean', description: 'true 开启,false 关闭' },
  },
  required: ['device'],
}

映射表格式在注册那一刻不报错,问题只在"模型真正构造调用"时暴露——也就是说,失败面比注册面靠后。这条现在是工作区写死的规则之一:完整 JSON Schema({type:'object', properties, required}),空参数写 {type:'object', properties:{}}。我照做了,这个插件的几个查询工具全是空参数,形状就是那个空对象,不省略。

代价是很啰嗦。一个"列出设备"的工具要写六行 schema 来表达"我不要参数"。但省略的写法会让模型收到一个它读不懂的签名——它不会报错,它会开始猜参数名。

二、激活顺序不保证,所以 inject 不是可选的

我原以为 bundles 列表的顺序就是加载顺序,于是在列表里把自己的插件排到依赖后面,觉得稳了。

错的地方在于 loader 是并行加载的:bundles 顺序不构成服务就绪的保证。我在这个插件上亲身遇到了两种表现:

  • host 侧:subprocess 服务的原生模块初始化可能晚于工具注册,如果只在 applyctx.get('subprocess'),拿到的是 undefined
  • client 侧:我的面板可能先于提供 slots 服务的 ui-renderer 启动,于是 ctx.get('slots') 同样是 undefined,面板静默不出现。

修法是把硬依赖写进 inject,让 Cordis 负责等待就绪:

// Host
export const inject = ['tools', 'subprocess', 'webServer']
 
// Client
exports.inject = ['slots']

可选依赖才用 ctx.get(name) 判空,并且必须有降级路径。device-controltimer 就是可选服务:取不到就退化成"只在工具调用时同步一次状态",插件整体依然可用。

这条的推论比规则本身更重要:任何"它应该已经在了"的假设都要么写进 inject,要么写一段显式的降级。我见过一次真实的崩溃循环就是这个形状——某个插件在启动期断言"上游 settings 模块的依赖形状应该包含 X",dsh 换代后形状变了,于是配对完成后首页重写直接抛错。断言没写进依赖声明,事情就变成"自认为在依赖,实际在赌"。

三、子进程输出要自己收,collected 模式可能骗你

我在这个插件里调外部 CLI,第一版用的是 subprocess 的 collected 模式——它看起来更省事:跑完直接给你聚合好的输出。

症状非常难查:进程 exit code 是 0,stdout 却是空的。于是 JSON.parse('') 抛错,或者更糟,解析出一个空对象,然后你以为是外部系统没返回数据,开始怀疑凭证、网络、协议。我在这上面绕了一段不短的时间。

改成自己收集就稳了:

const handle = subprocess.spawn({
  argv: args,
  cwd: '/tmp',
  stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
  graceMs: 25000,
  signal,
})
const outChunks = []
const errChunks = []
if (handle.stdout) handle.stdout.on('data', (d) => outChunks.push(d))
if (handle.stderr) handle.stderr.on('data', (d) => errChunks.push(d))
const outcome = await handle.done
const text = Buffer.concat(outChunks).toString('utf8')     // 完整,不截断
const errText = Buffer.concat(errChunks).toString('utf8')  // 只用于展示
return { exitCode: outcome.exitCode, text, err: errText.slice(0, 500) }

配套还有一条纪律:JSON.parse 之前不要截断输出。截断只用在错误信息展示上(stderr 我截到 500 字符)。这两件事很容易混在一起做,因为它们都和"输出太长"有关——但一个是解析正确性,一个是展示美观。

我认为这里真正的教训不是"pipe 更好",而是:exit code 和 stdout 是两个独立信号,任何一个都不能单独当作成功的证据

四、手写 client bundle 有一个必写的 return

文件式插件的 client 半边我手写过一次(就是为了避开 tsc + tsdown 那套工具链)。结构长这样:

window.__ModuleLoader__.load({
  id: 'dsh-device-control',
  factory: (require) => {
    var module = { exports: {} }
    var exports = module.exports
    // ... 注册 slots、定义组件 ...
    exports.apply = function apply(ctx) { /* ... */ }
    return module.exports     // 少了这行,报 invalid plugin, received undefined
  },
})

漏掉 return module.exports,报错是一句 invalid plugin, received undefined。这句话完全不提 factory,也不提返回值,所以第一次看到它时我以为是 id 写错了或者包名没匹配上。

我提这条不是为了说"记得写 return",而是它代表了一类错误:加载期的报错信息往往指向症状而不是原因。同类还有一次是构建脚本漏复制了一个文件,表现为 ERR_MODULE_NOT_FOUND,我第一反应是"包没装上",查了半圈 lockfile 才发现是产物目录少了个 db.js

所以我现在判断"插件为什么没加载"的顺序固定为:先看产物目录里该有的文件在不在,再看加载期报错,最后才怀疑配置。

五、文件式 client 没有 remote,跨进程只能走 HTTP

这个我一开始理解反了。我以为插件既然 provide 了一个服务(这个插件里是 deviceControl),client 侧就能像调用本地对象一样调它。

不能。文件式 client bundle 没有 remote / host 这两个 builtin——那是动态插件专属的能力。文件式插件的 client 半边要拿 host 侧的数据,只有一条路:host 用 webServer 注册路由,client 用同源 fetch 去取。

// Host:每条路由都挂在 ctx.effect 上,卸载时自动清理
ctx.effect(() => ctx.webServer.register({
  kind: 'exact',
  path: '/api/device/status',
  handler: (req, res) => handleApi(req, res, () => service.getStatus()),
}), 'device-control: /api/device/status')
// Client:同源请求自动带会话 cookie,不用自己处理鉴权
const device = {
  getStatus: () => fetchJson('/api/device/status'),
  control: (args) => fetchJson('/api/device/control', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(args),
  }),
}

看着比 RPC 原始,但它是这个形态下最简单且唯一自洽的方案。附带的好处是:同源 fetch 天然带上会话 cookie,不需要我再设计一层鉴权。

我把这条归纳成一句话:先确定插件形态,再决定前后端怎么通信。形态决定了能力边界,而不是反过来。

六、工具描述比工具参数更影响成功率

这条最反直觉,也最有用。

device_control 的模式参数是整数 0/1/2。我在参数 schema 的 description 里写了"0=自动,1=省电,2=强力",但最初工具级描述只写了"控制设备"。结果模型传过 "auto" 这样的字符串——它把参数当成自然语言槽位在填。

把数字含义写进参数描述后问题消失了。更进一步的是 device_capabilities 的描述:

能力总览:每个受控设备已具备的能力(可读、可写、单位与取值边界等)、当前值与来源服务。用于判断某台设备已有哪些能力,避免重复登记同一项能力。

最后那半句"避免重复登记同一项能力"是写给模型的行为约束,不是给人的说明。加上之后,它问能力时会先读总览,只报真实存在的能力,而不是顺手编一项没人登记过的能力。

我现在的写法习惯是:工具描述写"什么时候该调用我、调用我之前该先做什么",参数描述写"取值边界和单位"。前者是行为规范,后者是数据契约,混在一起写两头都不清楚。

七、展示数据实时读,硬编码会过期

这个插件的面板最初把设备名和分组名都写死在映射函数里。功能正常,直到一次分组调整——工具返回的还是旧分组名。

后来改成分组名从建模库查:

const row = db.prepare(
  'SELECT g.name AS name FROM devices d LEFT JOIN groups g ON g.id = d.group_id WHERE d.device_id = ?',
).get(String(deviceId))

现在设备的分组归属以库里最新同步为准。硬编码只剩设备清单本身(几个 device_id 与展示名),那是我明确接受的临时债。

这条泛化一下:凡是"权威源在服务端、展示端只是视图"的数据,都不要在 client 或视图函数里留常量。数据迁移、重新归属、改名,这些操作不会去改你的视图函数。

八、错误要结构化,但也不能全吞

工具执行失败时我返回结构化对象,不抛异常:

return { ok: false, error: '控制失败: ' + r.text.slice(0, 300) }

好处是模型能读到 error 字段,自己决定是换个参数重试还是汇报给我。如果抛异常,模型这轮通常就断了,只能等下一轮。

但"全吞"是另一个极端。这个插件的 host 侧最外层 apply 是整体 try/catch 且不冒泡的(避免触发 quarantine),这带来一个直接后果:插件加载失败时,开发者收不到任何信号

我的补偿办法是加一条只追加日志的诊断路由,client 侧每个关键步骤都往上报一次:

const report = (step, extra) => {
  window.fetch('/api/device/diag', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ kind: 'device-client', step: step, extra: extra || null }),
  }).catch(() => {})
}
report('apply-start')
report('slots-get', String(slots !== undefined))
report('inject-left-fired')

面板第一次没渲染出来时,就是靠 slots-get=false 这一行定位到"slots 服务还没就绪"的。没有它,我看到的只是"面板没出来"。

这条的结论有点像悖论:隔离异常和可观测性是两个必须同时满足的目标,只用 try/catch 只能满足前一个

九、版本兼容是插件作者的日常,不是升级那天的事

DSH 处于 developer preview,破坏性变更很密。我实际撞上的有:dsh.bundle 顶层字段改成嵌套的 dsh;投影契约从 schema 改成 stateSchema;schemastery 的 .optional() 被移除;settings helper 被移除。社区 200 个插件的分析里也提到同类问题,建议插件声明精确的 peerDependencies(例如限定在某个次版本区间内)并在 README 标注门槛版本。

我现在维护的规矩是:插件侧用 peerDependencies 列出实测过的版本,用 || 串联,不用 * 也不用过宽区间;核心侧维护一张"插件 × dsh 版本"的矩阵,dsh 升级时逐行核对,全绿才算升级完成。

有个例外我也用了:不 import 任何 dsh 包的文件式 bundle 可以不声明版本——因为没有 ABI 可谈,兼容性交给组合层。但这条豁免的前提是"真的不 import",一旦插件 import 了 dsh 包,就必须声明。同工作区里另一个文件式插件就因为 import 了包而用 dependencies 做了版本声明,后来被建议改成 peerDependencies——运行时那份副本应该由 host 提供,而不是插件自己拖进来一份。

小结

这些结论里,我觉得最能省时间的是三条:

  1. 注册成功不代表模型能调用——schema 不完整时错误会推迟到模型面才出现;
  2. 顺序不能赌——依赖写进 inject,可选依赖写降级;产出目录、加载报错、配置这三者的排查顺序也要固定下来;
  3. 描述是行为契约——写清"什么时候调用我"比多写一个参数更有效。

其余的(自己收子进程输出、展示数据实时读、结构化错误但别全吞)都属于"知道一次就不会再犯"的类型。写下它们的原因很简单:我在这几条上各绕了一圈,而每一圈的原因都不是知识不够,是当时的直觉正好相反。

目录