02. 让 Agent 有手有脚:工具系统的设计与演化

📅 2026/7/2 1:18:31
02. 让 Agent 有手有脚:工具系统的设计与演化
02. 让 Agent 有手有脚工具系统的设计与演化从零到一实现一个 AI Agent 框架 · 第二篇1. 为什么需要工具系统上一篇我们实现了 Agent Loop——LLM 能自己决定下一步做什么了。但注意那个循环里最关键的一步我们跳过了LLM我想查 AAPL 的股价 循环好那你去吧 → 怎么查谁来执行Agent Loop 负责决定做什么工具系统负责真正去做。没有工具系统Agent 就是个空转的大脑——想了很多但什么都做不了。最早期的 Agent 实现里工具就是一段 if-elseifnameget_weather:returnget_weather(args)elifnamesearch_web:returnsearch_web(args)# ... 每加一个工具就加一个 elif但随着工具变多问题就来了每个工具的参数怎么校验谁来判断这个工具是只读的还是破坏性的工具输出太大怎么办直接塞回上下文那么多工具模型每次都得看全部 schema浪费 token这些问题就是工具系统要解决的。2. 从零开始最小工具系统先把问题简化到极致——一个工具系统最少需要什么# 最小工具系统tools{}defregister_tool(name,fn,description,parameters):注册一个工具tools[name]{fn:fn,schema:{name:name,description:description,parameters:parameters}}defdispatch(name,args):调用一个工具ifnamenotintools:returnfError: unknown tool {name}returntools[name][fn](**args)defget_schemas():获取所有工具的 schema传给 LLMreturn[t[schema]fortintools.values()]就这么简单注册 → 生成 Schema → 分发调用。来注册两个工具试试defget_weather(city):returnf{city}的天气晴22°Cdefsend_email(to,subject,body):returnf邮件已发送到{to}register_tool(get_weather,get_weather,description获取城市天气,parameters{type:object,properties:{city:{type:string,description:城市名}},required:[city]})register_tool(send_email,send_email,description发送邮件,parameters{type:object,properties:{to:{type:string},subject:{type:string},body:{type:string}},required:[to,subject,body]})然后和上一篇的 Agent Loop 接起来# Agent Loop 里调用工具的代码fortool_callinmsg.tool_calls:resultdispatch(tool_call.function.name,json.loads(tool_call.function.arguments))messages.append({role:tool,content:result})这就是最小可用方案了。但和第一篇一样这版本也有很多工程问题等着解决。3. 工程演进工具系统需要解决什么3.1 工具 Schema 怎么生成上面的代码里parameters 是手写的 JSON。手写的问题容易出错类型写错、漏了字段难以维护函数改参数了但 JSON 没同步更新不够精确JSON Schema 表达能力有限更好的做法是从类型定义自动生成Schema。在 TypeScript 里可以用zod或json-schema这类库从函数签名推导出 OpenAI Function Calling 兼容的 Schema。// 理想方案从类型定义自动生成constweatherToolcreateTool({name:get_weather,description:获取城市天气,input:z.object({// 用 zod 定义参数city:z.string().describe(城市名)}),handler:async({city}){return${city}的天气晴22°C;}});// → 自动生成 OpenAI 兼容的 schema3.2 工具多了怎么办假设你有 20 个工具每次调用 LLM 都要把 20 个完整 Schema 传过去。这会浪费 token每个 Schema 几百到上千 token20 个就是上万干扰决策模型要从 20 个里选容易选错解决方案按需加载Deferred Tools。常用工具常驻低频工具默认隐藏。模型先调tool_search搜索再把匹配的工具激活到下一轮。# 不是所有工具都在 Schema 里active_tools[read_file,write_file,bash,search_files]# 需要记忆工具先搜一下# LLMtool_search(querymemory)# 系统找到 memory_save / memory_read激活下一轮可用3.3 工具输出太大怎么办工具可能返回巨大结果npm test→ 几百行测试日志search_files(TODO)→ 命中 50 个文件read_file(large.json)→ 几万行的 JSON把这些原样塞回上下文后果很严重上下文窗口爆炸LLM 调用变贵关键信息被淹没模型找不到重点可能触发上下文超限整个请求失败常见的治理策略策略做法适合场景截断保留头尾 N 字符日志、测试输出摘要LLM 压缩成一句话搜索结果落盘保存到文件给模型路径超大输出过滤只返回关键行错误信息注意截断时优先保留尾部——很多工具的重要信息在末尾测试失败数、构建状态码、命令退出码。3.4 工具的安全性怎么保障不是所有工具都能随便调。考虑这几个场景LLM让我看看用户的 ~/.ssh/id_rsa 文件 → 危险 LLMrm -rf / → 非常危险 LLM帮我把这篇文章发布到生产环境 → 需要确认Axon 的做法是给每个工具打标签让系统层做决策isReadOnly: 只读工具可以并发、不需要确认isDestructive: 破坏性操作必须要用户确认isConcurrencySafe: 是否可以和其他工具并行执行而且这些标签可以是输入相关的bash(ls)是只读的bash(rm -rf /)不是。4. 代码解剖Axon 的工具系统Axon 的工具系统核心在src/tools/index.ts。几个关键概念4.1 ToolSpec工具的完整契约每个工具不是一个简单的name→function映射而是一个ToolSpecinterfaceToolSpec{name:string;// 工具名definition:object;// OpenAI Function Calling Schemahandler?:ToolHandler;// 实际执行函数// 行为元数据输入相关isReadOnly?:(input)boolean;isConcurrencySafe?:(input)boolean;isDestructive?:(input)boolean;// 结果治理maxResultSizeChars?:number;// 默认 50_000// 延迟加载deferred?:boolean;}关键设计isReadOnly等元数据接收 input 参数。同一个工具在不同输入下有不同的行为特征。4.2 工具注册所有工具汇总到一个注册中心// 核心工具 扩展工具 MCP 工具 → 合并 → 按需激活functiongetActiveToolSpecs():ToolSpec[]{return[...coreTools,// read_file, bash 等常驻...extensionTools,// task, memory 等...activatedDeferred,// 通过 tool_search 激活的...mcpTools// 动态注入的 MCP 工具];}4.3 工具执行链路一次工具调用的完整链路结果治理工具处理函数权限检查dispatchAgent LoopLLM结果治理工具处理函数权限检查dispatchAgent LoopLLMalt[allow][deny]tool_call(name, args)dispatch(name, input)查找 ToolSpeccheckPermission(name, input)allow / deny / confirmhandler(input)原始输出脱敏mask secrets截断 / 落盘处理后的结果Permission deniedtool role message几个要点工具找不到返回Error: unknown tool不是抛异常——让 LLM 自己修正权限拒绝也作为工具结果返回不中断 Agent Loop所有结果都经过脱敏和审计4.4 并发执行当 LLM 一次请求多个工具调用时Agent Loop 会判断是否可以并行constcanRunConcurrentlycalls.length1calls.every(cisConcurrencySafe(c.name,c.input));if(canRunConcurrently){awaitPromise.all(calls.map(cdispatch(c)));}else{for(constcofcalls){awaitdispatch(c);}}Axon 的做法是整批判断只要有一个工具不能并发整批串行。更激进的策略可以分组并发先读后写但会让执行顺序和审计变得复杂。4.5 文件编辑安全edit_file是最容易出问题的工具Axon 做了三层保护第一层Read-before-edit编辑前必须调用过read_file否则拒绝。第二层mtime 检查读取时记录文件的修改时间戳写入时检查是否被外部修改过。第三层唯一匹配替换使用 search-and-replace 而不是行号编辑// 要求old_string 在文件中出现且只出现一次 // 出现 0 次 → 模型幻觉拒绝 // 出现 1 次 → 正常替换 // 出现多次 → 要求提供更多上下文这样就把模型的幻觉修改变成了显式失败。5. 动手实验搭建自己的工具系统基于上一篇的 Agent Loop加上工具系统。importjsonfromopenaiimportOpenAI clientOpenAI(api_keyyour-api-key)# 工具注册中心 tools_registry{}defregister(name,handler,definition):tools_registry[name]{handler:handler,definition:definition}defdispatch(name,args):ifnamenotintools_registry:returnfError: unknown tool {name}returntools_registry[name][handler](**args)defget_schemas():return[t[definition]fortintools_registry.values()]# 注册工具 defget_weather(city):returnjson.dumps({city:city,temp:22,condition:晴})defcalculate(expression):try:returnstr(eval(expression))exceptExceptionase:returnfError:{e}register(get_weather,get_weather,{type:function,function:{name:get_weather,description:获取城市天气,parameters:{type:object,properties:{city:{type:string}},required:[city]}}})register(calculate,calculate,{type:function,function:{name:calculate,description:执行数学计算,parameters:{type:object,properties:{expression:{type:string,description:数学表达式}},required:[expression]}}})# Agent Loop接上第一篇 defagent_loop(prompt):messages[{role:user,content:prompt}]max_turns10forturninrange(max_turns):print(f\n--- Turn{turn1}---)responseclient.chat.completions.create(modelgpt-4o,messagesmessages,toolsget_schemas(),tool_choiceauto)msgresponse.choices[0].messageifnotmsg.tool_calls:returnmsg.content messages.append(msg)fortool_callinmsg.tool_calls:nametool_call.function.name argsjson.loads(tool_call.function.arguments)print(f→ 调用:{name}({args}))resultdispatch(name,args)print(f← 结果:{result[:100]}...)messages.append({role:tool,tool_call_id:tool_call.id,content:result})return已达最大轮数。# 试试看resultagent_loop(北京天气怎么样再帮我算一下 2^10 等于多少)print(f\n最终回复{result})实验一下加一个新工具比如search_web(query)返回模拟结果让工具返回错误get_weather返回error: 服务不可用看 LLM 怎么应对尝试危险操作加一个delete_file(path)工具观察 LLM 是否会在没有确认的情况下调用下一篇预告别让 Agent 乱跑——权限与安全治理工具越多风险越大。怎么让 Agent 只能读不能删怎么保护 API Key 不被泄露怎么让危险操作需要用户确认下一篇讲权限和安全设计。