smolagents — 本课题摘录
读了哪几篇: 01-agent-loop(通用多步 ReAct 骨架)、02-code-agent(把行动写成代码)、04-tools(工具抽象)。
其余三篇(受限 Python 执行器、模型与记忆、远程执行与安全)本轮没读——属沙箱与权限边界课题。
这一家的价值在于:它对"怎么认出模型要调工具"给了一个完全不同的答案——不认,让模型直接写代码。
它对本课题回答了什么
决定二:怎么认出模型要调工具 —— 让它写代码,不写调用
传统工具调用一步只能干一件事,要"搜三个关键词、各取首条、拼起来"就得三四轮往返。让模型直接写一段代码,一步之内就能连调多个工具、把结果存进变量、写循环和判断。 (依据:前沿库 · smolagents · CodeAgent(把行动写成代码) —— CodeAgent 让模型输出一整段 Python 代码,工具是预先注入执行环境的 Python 函数,一步内可连调多个工具并使用变量与控制流)
它给的理由是:代码本来就是为"组合调用 + 控制流"设计的,用 JSON 表达这些反而别扭。
怎么把代码从自由文本里抠出来 —— 用成对标签框定代码区,配一套四级兜底:
| 级别 | 尝试什么 |
|---|---|
| 1 | 用配置的标签正则抓 |
| 2 | 抓不到就退回 markdown 代码围栏 |
| 3 | 还抓不到就试着把整段文本当代码解析 |
| 4 | 都失败,抛一个带"示范正确格式"的报错 |
(依据:前沿库 · smolagents · CodeAgent(把行动写成代码) —— parse_code_blobs 是四级兜底——配置标签正则 → markdown 围栏 → 整段文本当代码 ast.parse → 抛带示范格式的报错)
第 4 级的报错很讲究:文本里同时出现 "final" 和 "answer" 时,它会专门提示"你像是想给最终答案,应该这样写"。
这是全库反复出现的手法:报错本身就是给模型的教学。 跟 cline 的"错误即消息"是同一件事的两种说法。
停止序列的巧思: 把代码块的闭合标签加进停止序列, 模型一写完代码就停,不浪费 token 继续编"观测"。反过来,如果模型输出没以闭合标签结尾,手动补上并写回历史——既保证能抠出代码,又把这个结尾"示范"给后续调用,诱导模型养成早停习惯。 (依据: shelf=frontier/smolagents#02-code-agent @e3a5b8994b30 事实=把 加进停止序列让模型写完代码即停;输出未以闭合标签结尾时手动补上并写回历史,诱导模型早停)
决定四:什么时候停 —— 用异常穿透,而且必须绕过模型自己的 try/except
收工靠模型在代码里调一个特殊函数。 难点是:这个函数在模型写的代码内部被调用,系统怎么知道该结束整个运行?
答案是把它包一层,调用即抛异常,执行循环在顶层捕获,把异常带的值当最终答案。
最妙的一处细节:这个异常继承的是最顶层的基类,不是普通异常类。 (依据:前沿库 · smolagents · CodeAgent(把行动写成代码) —— FinalAnswerException 继承 BaseException 而非 Exception,因为模型写的代码里常有宽泛的 except Exception 会把「我要交答案」这个信号吃掉)
为什么: 模型写的代码里常有 try: ... except Exception: ...。如果这个异常是普通异常类,模型代码里一个宽泛的 except 就 会把"我要交答案"这个信号吃掉。继承最顶层基类让它绕过所有常规捕获,稳稳穿透到执行器顶层。
这条是"代码即动作"这条路独有的坑,而且极隐蔽。要抄这条路就必须抄这个细节。
还有一个小修补:模型有时写成 final_answer = 42 又 final_answer(...),把函数名覆盖成了变量——它用正则把这类误赋值修回来。
决定二补充:同一份工具元数据,两副面孔
一个工具 = 四个声明式属性(名 / 描述 / 入参 / 出参)+ 一个干实事的方法。 同一份元数据渲染出两种说明书:
| 面孔 | 给谁 | 长什么样 |
|---|---|---|
| 代码签名 | 写代码的那种 agent | 一个带文档字符串的 Python 函数声明 |
| JSON schema | 走原生工具调用的那种 agent | 厂商标准的 function schema |
(依据:前沿库 · smolagents · 工具抽象(一个函数的两副面孔) —— 同一份工具元数据用 to_code_prompt 渲染成带 docstring 的 Python 函数签名给 CodeAgent、用 to_tool_calling_prompt 渲染成 JSON schema 给 ToolCallingAgent)
这条对我们直接有用: 工具的定义应该跟"怎么讲给模型听"分开。一份定义,多种投影。
决定四:骨架与"一步怎么行动"解耦
骨架管:排第几步、要不要先规划、把记忆变成模型输入、捕获错误、判断到没到步数上限、什么时候算拿到最终答案。"一步怎么行动"是一个抽象方法,由子类填。 (依据:前沿库 · smolagents · 通用多步 ReAct 循环 —— MultiStepAgent 骨架负责重复/捕错/判终止,「一步怎么行动」是抽象方法 _step_stream 由子类实现,CodeAgent 与 ToolCallingAgent 各填一种)
错误不中断循环: 捕获到 agent 错误就记到这一步的记录里,下一轮重试。
整个循环用生成器驱动,每步产出的东西都往外抛。同一套代码既能"一次跑到底只要最后答案",也能"边跑边把中间过程吐给调用方"。
它的做法(可以抄的部分)
两条路线的取舍表(它自己给的,值得原样记住):
| 维度 | 代码即动作 | 传统工具调用 |
|---|---|---|
| 一步能做几件事 | 多个工具 + 变量 + 控制流 | 通常一个或几个并行调用 |
| 怎么执行 | 受限 Python 解释器 | 直接调函数 |
| 并行 | 由代码自身表达 | 线程池并行多个 调用 |
| 终止 | 代码里调特殊函数 → 抛异常 | 工具名等于那个特殊名字 |
| 依赖 | 不要求模型支持原生工具调用 | 依赖模型的工具调用能力 |
(依据:前沿库 · smolagents · CodeAgent(把行动写成代码) —— CodeAgent 与 ToolCallingAgent 的对比表明确列出「代码即动作不要求模型支持 function calling」这一条)
最后一行是关键:代码即动作这条路不挑模型。
它没回答什么
- 代码跑在哪、怎么不炸——受限解释器那一章本轮没读,属沙箱课题。
- 历史怎么压——这三篇没讲。
- 工具太多怎么办——工具全量注入执行环境,没有裁剪机制。
坑与代价
- "代码即动作"把安全问题整个提前了。 传统工具调用的攻击面是"参数不对",这条路的攻击面是"模型能写任意代码"。它必须配一个受限解释器,那是一整章的工程量。
判断(无锚): 对我们的最小原型,这条路先不走——我们只有一两个工具,代码带来的表达力用不上,却要背一个解释器。 如果错,会错在: 如果第一批工具就是"读文件/搜索/统计"这类需要 组合的,往返轮数会明显变多,那时代码即动作的省步数优势就体现出来了。
- 异常继承基类这个坑不写清楚就会踩。 上面那条细节是全库最容易被漏掉、漏掉就静默失效的地方。