工具调用拆解:为什么给 Agent 加能力,不用重写循环

张开发
2026/4/3 20:28:49 15 分钟阅读
工具调用拆解:为什么给 Agent 加能力,不用重写循环
工具调用拆解为什么给 Agent 加能力不用重写循环刚看完agents/s02_tool_use.py的时候我有一个很强烈的感觉: 这个例子表面上是在“加工具”本质上其实是在讲一件更重要的事:一个 Agent 真正稳定的部分往往不是它会多少工具而是它有没有一个不用反复推倒重来的执行闭环。如果说s01_agent_loop.py解决的是“Agent 为什么能动起来”那s02_tool_use.py解决的就是另一个非常实际的问题:当模型已经会循环调用工具之后怎么把新能力一项一项接进去而且越加越稳仓库 to-learn-learn-claude-code先说结论s02最值得学的不是“Agent 有了 4 个工具”而是下面这句话:给 Agent 加能力不一定要改循环本身很多时候只需要补两样东西: 工具 schema 和工具分发 handler。也就是说循环还是那个循环:把上下文和工具列表发给模型看模型有没有发起tool_use如果有就执行工具把结果包装成tool_result喂回模型直到模型不再请求工具真正变的是“模型能触达的外部动作”越来越多了。为什么只有 Bash 还不够在s01里模型只有一个bash工具。这个设计足够让 Agent 跑起来但如果继续往前做很快就会遇到几个问题。第一什么事都走 shell代价有点高。读文件要靠cat、改文件要靠sed、写文件要靠重定向。这些命令当然能用但它们对特殊字符、换行、转义和平台差异都比较敏感模型一旦生成得不够严谨就容易翻车。第二什么事都走 shell边界不够清晰。如果“读文件”只是一个 bash 命令那系统层面很难提前表达清楚: 这个动作到底只允许读当前工作区还是允许读整个机器如果把“读文件”单独做成工具很多限制就可以直接写进工具实现里。第三什么事都走 shell返回结果也不够结构化。模型虽然也能看懂 bash 输出但和“这是读文件结果”“这是编辑是否成功”相比shell 输出天然更杂、更噪也更依赖模型自己猜语义。所以s02的方向不是把 bash 去掉而是把那些高频、可控、适合抽象的动作拆成专用工具:bash: 继续兜底处理通用命令执行read_file: 专门读文件write_file: 专门写文件edit_file: 专门做精确文本替换这一步其实很像软件工程里常说的那句话:不是不要万能接口而是要把高频能力从万能接口里提纯出来。这份代码真正新增了什么如果只看“行为变化”你会觉得s02比s01多了不少内容但如果只看“架构变化”它其实只新增了三层东西。1. 新增了路径安全边界最容易被忽略但其实最重要的是safe_path():defsafe_path(p:str)-Path:path(WORKDIR/p).resolve()ifnotpath.is_relative_to(WORKDIR):raiseValueError(fPath escapes workspace:{p})returnpath这个函数做的事很朴素: 把外部传进来的路径解析成绝对路径然后确认它还在当前工作区里。它的意义不在于“代码复杂”而在于它明确划出了一条边界:模型可以读写文件但只能读写工作区里的文件一旦路径逃逸就直接报错这是我觉得s02比s01更像“真正工程代码”的地方。因为从这里开始工具不再只是功能入口也开始承担约束职责了。2. 新增了专用工具实现s02把常见文件操作拆成了几个独立函数:defrun_read(path:str,limit:intNone)-str:textsafe_path(path).read_text()linestext.splitlines()iflimitandlimitlen(lines):lineslines[:limit][f... ({len(lines)-limit}more lines)]return\n.join(lines)[:50000]defrun_write(path:str,content:str)-str:fpsafe_path(path)fp.parent.mkdir(parentsTrue,exist_okTrue)fp.write_text(content)returnfWrote{len(content)}bytes to{path}defrun_edit(path:str,old_text:str,new_text:str)-str:fpsafe_path(path)contentfp.read_text()ifold_textnotincontent:returnfError: Text not found in{path}fp.write_text(content.replace(old_text,new_text,1))returnfEdited{path}这几个函数有两个共同特点:都先经过safe_path()先管边界再谈能力都把结果收敛成简单字符串方便回传给模型尤其run_edit()很值得注意。它没有做 AST 级别编辑也没有做复杂 patch只是“找到旧文本替换一次”。这种设计看起来不高级但非常适合教学因为它把工具行为定义得足够简单模型也更容易学会正确调用。3. 新增了一个工具分发表这部分是s02最核心的抽象:TOOL_HANDLERS{bash:lambda**kw:run_bash(kw[command]),read_file:lambda**kw:run_read(kw[path],kw.get(limit)),write_file:lambda**kw:run_write(kw[path],kw[content]),edit_file:lambda**kw:run_edit(kw[path],kw[old_text],kw[new_text]),}这张表的作用很直接:模型返回工具名程序按工具名查字典找到对应的 Python 函数执行它看起来像个小细节但实际上帮我们避开了两个问题。第一个问题是避免if/elif越写越长。第二个问题是新增工具的成本变得非常稳定。以后要加一个新工具通常只要做三件事:写一个处理函数把它注册到TOOL_HANDLERS把对应 schema 注册到TOOLS只要这三步做好主循环大概率不用动。整体结构图: s02 到底怎么跑起来先看一眼整体结构会更容易抓住重点:tool_use最终文本回复用户输入history 对话历史agent_loopLLMTOOLS 工具定义TOOL_HANDLERS 分发表run_bashrun_readrun_writerun_editsafe_pathtool_result这个图里我觉得最值得盯住的不是“工具变多了”而是两条主线:一条是从tool_use到TOOL_HANDLERS的“分发线”一条是从tool_result回到history的“闭环线”前者决定系统怎么执行动作后者决定系统怎么继续思考。Agent Loop 其实几乎没变这是整个s02最有启发性的地方。很多人第一次做 Agent会下意识把“能力变多”理解成“主循环要变复杂”。但这份代码恰恰说明了好的主循环应该尽量稳定能力扩展应该往工具层去长。agent_loop()的关键部分其实还是那几步:forblockinresponse.content:ifblock.typetool_use:handlerTOOL_HANDLERS.get(block.name)outputhandler(**block.input)ifhandlerelsefUnknown tool:{block.name}results.append({type:tool_result,tool_use_id:block.id,content:output,})这里真正新加的只有一句:handlerTOOL_HANDLERS.get(block.name)也就是说s01里“看到工具调用就执行 bash”的位置在s02里被替换成了“先查工具名再路由到对应实现”。这个变化虽然小但设计味道完全不一样了:s01更像一个单功能 demos02开始具备“可扩展工具平台”的雏形一次完整请求的时序图如果把一次“读文件再回答”的过程画成时序图大概是这样:File ToolTool DispatchLLMAgent Loop用户File ToolTool DispatchLLMAgent Loop用户输入任务messages system toolstool_use(read_file)按工具名查 handlerrun_read(path, limit)文件内容 / 错误信息tool_result把 tool_result 追加回 messages最终回答或继续请求工具输出结果这个过程里我有一个自己的理解:Agent 的关键不是“模型会不会主动调用工具”而是“工具执行以后模型能不能继续站在结果上推理”。所以tool_result不是附属品它就是 Agent 的第二条生命线。TOOLS和TOOL_HANDLERS一个对模型一个对程序s02里有一组很容易混淆但必须分开的概念:TOOLS和TOOL_HANDLERS。它们看起来都和“工具”有关但职责完全不同。组件面向谁作用TOOLS面向模型告诉模型有哪些工具可用、每个工具的参数长什么样TOOL_HANDLERS面向宿主程序告诉 Python 收到某个工具名后真正该执行哪个函数你可以把它们理解成:TOOLS是“工具菜单”TOOL_HANDLERS是“后厨调度表”菜单是给顾客看的调度表是给厨房看的。少一个都不行。如果只有TOOLS没有TOOL_HANDLERS模型知道能点菜但后厨没人做。如果只有TOOL_HANDLERS没有TOOLS后厨虽然会做但模型根本不知道可以点什么。我从这个例子里真正记住的 4 件事1. 循环是骨架工具是插件s02最打动我的地方是它让我更明确地把 Agent 分成两层来看:上层是稳定的执行闭环下层是可持续扩展的工具能力这两层分开以后系统就不容易“每加一个功能就重写一次主流程”。2. 专用工具比万能 Bash 更适合做可控系统不是说 bash 不好而是 bash 太宽了。教学和工程里都一样高频动作最好做成专用接口因为那样更容易:做边界限制做参数校验做结果收敛做错误反馈3. 打印给人看不等于喂给模型看代码里有一行:print(f{block.name}:{output[:200]})这只是给终端里的人类一个预览。真正让 Agent 继续推进任务的不是这行输出而是后面的tool_result回写。这个区别非常重要。很多初学者第一次写 Agent容易把“终端里看到了结果”误认为“模型也已经把结果纳入思考”。其实不是只有结果进了messages模型才算真的“看见了”。4. 安全边界应该前置到工具层safe_path()让我意识到一个很实用的原则:不要把安全寄希望于模型永远听话最好把安全写进工具本身。模型再聪明也可能犯错但工具层的约束一旦写死系统整体就会稳很多。和 s01 对比s02 到底多了什么维度s01s02工具数量只有bashbash read_file write_file edit_file工具调用方式写死调用 bash通过分发表动态路由文件边界控制基本没有safe_path()限制工作区主循环结构已经成立基本不变可扩展性演示级开始具备工程化味道这张表里最关键的一行其实是“主循环结构: 基本不变”。因为这说明了一个很有价值的工程方向:把主循环写稳把新增能力做成可注册的工具。最后总结s02_tool_use.py真正让我学到的不是“如何一次性堆很多工具”而是“如何用一种很克制的方式让 Agent 的能力自然长出来”。它没有把主循环写得越来越臃肿而是把新增复杂度放到了更合适的位置:用TOOLS告诉模型“你能做什么”用TOOL_HANDLERS告诉程序“你该怎么做”用safe_path()告诉系统“你最多做到哪一步”从这个角度看s02其实是一个很标准的 Agent 工程入门示例:循环负责稳定工具负责扩展结果回写负责闭环。这三件事一旦分清楚后面再往上加 Todo、子代理、上下文压缩思路都会顺很多。致谢学习主线参考并受益于shareAI-lab/learn-claude-code

更多文章