代码语言

知识点思维导图

29 个知识节点

Harness Engineering(04) - 工具注册、路由与执行

读完后,你应能完成以下任务:

  • 绘制“Harness Engineering(04) - 工具注册、路由与执行 / 工具调用的本质:模型只是"点菜",上菜的是 harness”的关键对象与数据流,解释“它做的只是输出一段结构化的"我想调用 read_file,参数是 path=a.txt"——相当于在菜单上点了道菜。”,并用源码位置、日志或 Trace 标注证据。
  • 为“Harness Engineering(04) - 工具注册、路由与执行 / 写好工具 schema:description 是重灾区”设计正常与异常输入,验证“一句话说清"干嘛用" -> 举例"什么时候该用我"(最关键,直接提升选择准确率) -> 标注边界/限制(覆盖范围、副作用、不能干什么)”,输出首个偏差位置与回归测试结果。
  • 实现“Harness Engineering(04) - 工具注册、路由与执行 / 工具一多,怎么让模型选得准”的最小代码或配置,检验“名字本身就是给模型的第一层提示。”,输出命令、结果与 Diff,并说明不适用边界。

第 03 章你的 Agent 已经会用 2 个工具了。但真实的 Agent 动辄几十个工具——这时新问题来了:模型怎么知道该用哪个?选错了怎么办?同时要用好几个怎么办?工具报错了会不会把整个 Agent 带崩? 这一章就解决"工具一多就乱"的问题。


一、工具调用的本质:模型只是"点菜",上菜的是 harness

先把一个最关键的认知钉死:

模型从头到尾没有执行过任何工具。 它做的只是输出一段结构化的"我想调用 read_file,参数是 path=a.txt"——相当于在菜单上点了道菜。真正下厨、上菜的,是 harness。

这个分工带来一个直接结论:工具好不好用,一半取决于"菜单"写得好不好。 菜单(schema)写得含糊,模型就点错菜。所以这一章很大篇幅在讲怎么写好"菜单"。


二、写好工具 schema:description 是重灾区

一个工具的 schema 有三个关键部分,重要性排序是:description > 参数描述 > 参数类型

新手最容易犯的错,是把 description 写成"我是什么",而不是"什么时候该用我":

为什么差别这么大?因为模型选工具时,眼里只有 description。它不会去读你的函数实现。description 就是工具的"自我介绍 + 使用说明",写得越具体,模型选得越准。

写 description 的口诀:

  1. 一句话说清"干嘛用"
  2. 举例"什么时候该用我"(最关键,直接提升选择准确率)
  3. 标注边界/限制(覆盖范围、副作用、不能干什么)

三、工具一多,怎么让模型选得准

当工具从 2 个涨到 20 个,光靠好 description 还不够,还有几招:

① 工具别太多、别功能重叠。 如果有 read_fileread_text_file 两个看起来差不多的工具,模型会犹豫、会选错。功能重叠的工具要合并。经验上,一个 Agent 的工具控制在十几个以内最好用。

② 工具命名要"见名知意"。 get_user_orders 远胜于 guofetch_data_v2。名字本身就是给模型的第一层提示。

③ 用参数而不是用新工具。 与其做 read_first_line / read_last_line 两个工具,不如做一个 read_file(path, mode),让 mode 区分。工具数量越少,模型的选择负担越小。


四、一圈调多个工具:并行 vs 串行

模型在一圈里可以同时请求多个工具。比如你问"对比 a.txt 和 b.txt",它可能一次性请求 read_file(a)read_file(b)

harness 要处理好这种情况:

能并行就并行。 上面这些工具之间互不依赖(读 a 不依赖读 b 的结果),可以并发执行,大幅提速:

⚠️ 但要注意:有依赖关系的操作不能并行。比如"先创建文件夹再往里写文件",模型通常会分成两圈来做(先建、看到成功、再写),harness 不用操心;但如果它在同一圈里又建又写,并行就会出错。好在模型一般能自己判断这种依赖。


五、工具报错:把错误当"观察结果",而不是让程序崩

这是 demo 到产品最重要的一道坎。工具执行会失败——文件不存在、网络超时、参数非法……

错误做法:让异常往上抛,整个 Agent 崩溃。 正确做法:捕获异常,把错误信息当成一种正常的"观察结果"喂回给模型,让它自己决定怎么办。

这样做的妙处:模型收到"⚠️ 文件不存在"后,往往会自己纠错——比如先调 list_files 看看正确的文件名,再重新读。Agent 的"自愈能力",很大程度就来自这种"把错误喂回去"的设计。


六、安全:别让模型的输出直接拼进命令

如果你有个 run_command(cmd) 工具,直接把模型给的字符串塞进 os.system()——这是重大安全隐患。模型可能(被诱导)生成 rm -rf / 这种东西。

防护原则:

  • 能用结构化参数就别用裸字符串。 比如删文件用 delete_file(path) 而不是 run_command("rm " + path)
  • 真要执行命令,用参数数组而非字符串拼接subprocess.run(["ls", path]) 而不是 subprocess.run("ls " + path, shell=True),避免命令注入。
  • 危险工具加确认/白名单。 这部分第 08 章会专门深入。

七、常见误区

误区 1:工具越多越强。 恰恰相反,工具过多会让模型选择困难、容易选错。少而精好过多而杂。

误区 2:description 写给人看。 它是写给模型看的"选择依据"。别写营销文案,要写清"何时用、有何限制"。

误区 3:工具报错就让程序崩。 要把错误喂回模型,给它自愈的机会。

误区 4:把模型输出直接拼进 shell / SQL。 永远把模型输出当"不可信输入",用参数化方式处理。


八、最佳实践

description 里务必写"什么时候用我"和"我的边界",这是提升工具选择准确率性价比最高的事。

工具数量克制,功能重叠的合并,用参数代替增加新工具。

统一用一个 safe_call 包装所有工具执行,把异常转成喂回模型的文本。

互不依赖的工具调用并行执行,提速明显。

危险操作走结构化参数 + 参数数组,杜绝注入。


九、动手实践:多工具 Agent:选择、并行与自愈

  • 选得准:根据问题自动挑对工具(读文件 / 算数 / 查天气)
  • 自愈:工具报错时,错误被当成"观察结果"喂回,Agent 能自己纠错
  • schema 的威力:每个工具的 description 都写清了"什么时候用我"

三个工具:

工具 作用
read_file 读文件内容
calculate 计算数学表达式
get_weather 查天气(mock 假数据)

9.1 怎么跑

默认离线 mock,无需 API Key:

python agent.py

它会依次跑几个不同类型的问题,让你看到 Agent 每次都选了对的工具:

  • "帮我算一下 (3 + 5) * 2" → 选 calculate
  • "北京今天天气怎么样" → 选 get_weather
  • "读一下 不存在.txt" → 选 read_file,报错后自动改去读存在的文件(自愈演示)

9.2 看点

  1. 工具选择:对照每个工具 schema 里的 description,理解模型为什么这么选。
  2. 自愈过程:留意"读不存在文件 → 报错 → Agent 改读别的文件"这一段,这就是"把错误喂回去"带来的自愈。
  3. 试着把某个工具的 description 改烂(比如只写"工具"),再看模型会不会选错——直观感受 description 的重要性。

十、总结

  • 工具调用的本质:模型只是"点菜",上菜的是 harness:它做的只是输出一段结构化的"我想调用 read_file,参数是 path=a.txt"——相当于在菜单上点了道菜。
  • 写好工具 schema:description 是重灾区:一句话说清"干嘛用" -> 举例"什么时候该用我"(最关键,直接提升选择准确率) -> 标注边界/限制(覆盖范围、副作用、不能干什么)
  • 工具一多,怎么让模型选得准:名字本身就是给模型的第一层提示。
  • 一圈调多个工具:并行 vs 串行:⚠️ 但要注意:有依赖关系的操作不能并行。
  • 工具报错:把错误当"观察结果",而不是让程序崩:这是 demo 到产品最重要的一道坎。
  • 安全:别让模型的输出直接拼进命令:如果你有个 run_command(cmd) 工具,直接把模型给的字符串塞进 os.system()——这是重大安全隐患。

10.1 可运行实验:编程工具调用与路径安全

<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>AC-05 在线实验</title>
  <style>
    :root{color-scheme:dark;font-family:Inter,system-ui,sans-serif}*{box-sizing:border-box}body{margin:0;background:#0f1211;color:#e7ece9;font-size:13px}.shell{padding:16px}.top{display:flex;justify-content:space-between;gap:16px;margin-bottom:14px}h1{margin:3px 0;font-size:18px}.id,.value{color:#68e0b5;font-family:ui-monospace,monospace}.summary{margin:4px 0;color:#a5afa9}.run{border:0;border-radius:6px;background:#68e0b5;color:#07110d;padding:8px 14px;font-weight:700}.grid{display:grid;grid-template-columns:minmax(220px,.8fr) minmax(0,1.8fr);gap:12px}.panel{border:1px solid #29322e;background:#141817;padding:12px}.control{display:grid;gap:5px;margin-bottom:11px}.head{display:flex;justify-content:space-between;gap:8px}select,input{width:100%;accent-color:#68e0b5;background:#0d100f;color:#e7ece9}.toggle{display:flex;justify-content:space-between;border-top:1px solid #29322e;padding-top:9px}.toggle input{width:18px}.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px}.metric{border:1px solid #29322e;padding:8px}.metric b{display:block;color:#68e0b5;font-size:16px}.stages{display:flex;gap:6px;overflow:auto;margin:10px 0}.stage{border:1px solid #8a6230;padding:7px;min-width:90px}.stage.ok{border-color:#367a61}.stage.fail{border-color:#8b4545}table{width:100%;border-collapse:collapse}td{border-top:1px solid #29322e;padding:7px}.diagnosis{margin-top:9px;border-left:3px solid #68e0b5;background:#101412;padding:9px;line-height:1.5}.danger{border-color:#ef7f7f}@media(max-width:680px){.top,.grid{display:grid;grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}}
  </style>
</head>
<body>
  <main class="shell">
    <header class="top"><div><div class="id">AC-05 · DETERMINISTIC LAB</div><h1 id="title"></h1><p class="summary" id="summary"></p></div><button class="run" id="run">运行实验</button></header>
    <section class="grid"><div class="panel"><div id="controls"></div><label class="toggle"><span>注入典型故障</span><input id="failure" type="checkbox"></label></div><div class="panel"><div class="metrics" id="metrics"></div><div class="stages" id="stages"></div><table><tbody id="rows"></tbody></table><div class="diagnosis" id="diagnosis"></div></div></section>
  </main>
  <script>
    const scenario = { title: '编程工具调用与路径安全', summary: '在虚拟工作区中校验工具名、参数 Schema、路径与补丁验证。', controls: [
        { key: 'tool', label: '调用工具', type: 'select', value: 'apply_patch', options: [['read_file', 'read_file'], ['search_code', 'search_code'], ['apply_patch', 'apply_patch'], ['shell', 'shell']] },
        { key: 'path', label: '目标路径', type: 'select', value: 'safe', options: [['safe', 'src/formatter.ts'], ['outside', '../../secrets.env'], ['empty', '空路径']] },
        { key: 'postCheck', label: '写后检查', type: 'select', value: 'tests', options: [['none', '不检查'], ['diff', '检查 Diff'], ['tests', 'Diff + Tests']] }
      ] };
    const controls = document.querySelector('#controls');
    const failure = document.querySelector('#failure');
    document.querySelector('#title').textContent = scenario.title;
    document.querySelector('#summary').textContent = scenario.summary;
    function renderControl(control) {
      const label = document.createElement('label'); label.className = 'control';
      const head = document.createElement('span'); head.className = 'head'; head.innerHTML = '<span>' + control.label + '</span><span class="value" data-value="' + control.key + '"></span>'; label.appendChild(head);
      const input = document.createElement(control.type === 'select' ? 'select' : 'input'); input.dataset.key = control.key;
      if (control.type === 'select') control.options.forEach(option => { const item = document.createElement('option'); item.value = option[0]; item.textContent = option[1]; item.selected = option[0] === control.value; input.appendChild(item); });
      else { input.type = 'range'; input.min = control.min; input.max = control.max; input.step = control.step || 1; input.value = control.value; }
      input.addEventListener('input', updateValues); label.appendChild(input); return label;
    }
    function updateValues() { scenario.controls.forEach(control => { const input = controls.querySelector('[data-key="' + control.key + '"]'); document.querySelector('[data-value="' + control.key + '"]').textContent = control.type === 'select' ? input.options[input.selectedIndex].text : input.value + (control.suffix || ''); }); }
    function readValues() { const values = {}; scenario.controls.forEach(control => { const input = controls.querySelector('[data-key="' + control.key + '"]'); values[control.key] = control.type === 'range' ? Number(input.value) : input.value; }); values.failure = failure.checked; return values; }
    function stage(name, state, detail) { return { name, state, detail }; }
    const aiStage = stage;
    function clamp(value, minimum, maximum) { return Math.min(maximum, Math.max(minimum, value)); }
    function simulate(values) { const fail = values.failure;
          /** 工具名是否在可信白名单内。 */
          const knownTool = ['read_file', 'search_code', 'apply_patch'].includes(values.tool);
          /** 目标路径是否已解析且位于工作区。 */
          const safePath = values.path === 'safe';
          /** 写操作后是否有足够验证。 */
          const verified = values.tool !== 'apply_patch' || values.postCheck === 'tests';
          /** 工具调用是否满足全部执行前条件。 */
          const accepted = knownTool && safePath && verified && !fail;
          return { metrics: [[accepted ? 'EXECUTED' : 'BLOCKED', '调用状态'], [knownTool ? 'VALID' : 'UNKNOWN', 'Tool Schema'], [safePath ? 'INSIDE' : 'REJECTED', '路径边界'], [verified ? 'YES' : 'NO', '写后验证']], stages: [stage('工具发现', knownTool ? 'ok' : 'fail', values.tool), stage('参数校验', values.path === 'empty' ? 'fail' : 'ok', values.path), stage('路径解析', safePath ? 'ok' : 'fail', safePath ? 'workspace' : 'outside'), stage('执行', accepted ? 'ok' : 'fail', accepted ? 'virtual repo' : 'blocked'), stage('验证', verified ? 'ok' : 'warn', values.postCheck)], rows: [['虚拟目标', values.path === 'safe' ? '/workspace/src/formatter.ts' : values.path === 'outside' ? '/secrets.env' : '(empty)'], ['危险命令', values.tool === 'shell' ? '不在工具白名单,模型文本不会直接进入 Shell' : '未请求 Shell'], ['故障注入', fail ? 'Schema 字段缺失,执行前拒绝' : '参数结构完整']], diagnosis: accepted ? '工具调用通过白名单、Schema、路径和验证四层检查。' : '调用被安全层阻断,未发生真实文件写入。', danger: !accepted };
         }
    function render() { const result = simulate(readValues()); document.querySelector('#metrics').innerHTML = result.metrics.map(item => '<div class="metric"><b>' + item[0] + '</b><span>' + item[1] + '</span></div>').join(''); document.querySelector('#stages').innerHTML = result.stages.map(item => '<div class="stage ' + item.state + '"><b>' + item.name + '</b><div>' + item.detail + '</div></div>').join(''); document.querySelector('#rows').innerHTML = result.rows.map(item => '<tr><td>' + item[0] + '</td><td>' + item[1] + '</td></tr>').join(''); const diagnosis = document.querySelector('#diagnosis'); diagnosis.textContent = result.diagnosis; diagnosis.className = 'diagnosis' + (result.danger ? ' danger' : ''); }
    scenario.controls.forEach(control => controls.appendChild(renderControl(control))); updateValues(); document.querySelector('#run').addEventListener('click', render); render();
  </script>
</body>
</html>

学完自测

选择所有正确答案;提交后逐项核对判断依据。

1在“工具注册、路由与执行”中,需要同时满足“工具调用的本质:模型只是"点菜",上菜的是 harness”与“写好工具 schema:description 是重灾区”。给定正文约束“所以这一章很大篇幅在讲怎么写好"菜单"。”,哪些判断保持了原有处理机制?多选
2“工具注册、路由与执行”出现偏差:“在“工具注册、路由与执行 / 工具一多,怎么让模型选得准”中,即使不满足“③ 用参数而不是用新工具”,结果与副作用仍会保持不变。”已成为实际行为。围绕“工具一多,怎么让模型选得准”与“一圈调多个工具:并行 vs 串行”,哪些判断能定位被改变的职责或边界?多选
3评审“工具注册、路由与执行”方案时,验收条件包含“捕获异常,把错误信息当成一种正常的"观察结果"喂回给模型,让它自己决定怎么办。”。关于“工具报错:把错误当"观察结果",而不是让程序崩”与“安全:别让模型的输出直接拼进命令”的哪些决策符合正文机制?多选