过去一年多,我一直在折腾 AI Agent 的落地,从最早的 function calling 到后来全面转向 Code Mode,最强烈的感受是:这不是给 Agent 多添了一种调用方式那么简单,而是实打实的范式转移。工具调用时代,Agent 的“手”是别人预先给它的一堆瑞士军刀;到了 Code Mode,Agent 开始自己铸刀、自己造工具,甚至能根据执行反馈把刀磨得越来越趁手。这篇文章打算把我观察到的变化、踩过的坑、以及目前真正在用的选型策略一次性说清楚,适合正在做 Agent 开发、想搞清楚 Tool Use 和 Code Mode 到底该选哪个的工程师,也适合想理解 Agent 能力边界的产品经理。
1. 为什么说这轮变化不是多了一个功能,而是换了一套路
1.1 工具调用的本质:预定义技能列表里做选择题
先回顾一下工具调用(Tool Use / Function Calling)是怎么回事。开发者把 Agent 可能用到的能力全部注册成函数,每个函数都有名字、描述、参数 schema,模型拿到用户问题后,不是直接执行动作,而是输出一个“调用意图”,比如{"name":"get_weather","arguments":{'city':'北京'}}。宿主程序负责真正调用这个函数,再把结果塞回上下文,让模型继续推理。
这套机制的设计思路很像给 Agent 一本目录,它只能从目录里挑东西用。好处非常明显:可控、可审计、不会让模型乱来。比如你要做一个“查询天气”的 Agent,定义一个get_weather工具,十分钟就能跑通。生产项目里常见的 Slack 通知、Jira 建单、数据库查询,也都很适合用工具调用,因为动作本身就是固定的、粒度清晰的。
但问题也随之而来:Agent 的动作空间完全由开发者提前划定。今天是“查天气、查数据库、发邮件”,明天需求变成“从二十个表格里找到异常数据,生成图表,按业务线分别整理成报告”,你就要重新写一批新工具。而且,当任务需要多步组合、分支判断、循环处理时,模型的决策方式仍然是每步只从有限工具里选一个,这就很容易出现“每次调用都要靠上下文硬记状态”的尴尬局面。
1.2 Code Mode 的本质:把编程环境变成 Agent 的动作空间
Code Mode 的思路是:不要给 Agent 一本固定的工具目录,而是直接给它一个可以执行代码的环境。模型输出一段 Python 或者 JavaScript 代码,执行器负责运行,然后把标准输出、报错信息、返回值作为下一轮观测结果,让 Agent 继续修改代码。这样,Agent 的动作不再是“选择”某个现成函数,而是“编写”一段逻辑去达成目标。
对比一下就清楚了。工具调用相当于让 Agent 点菜,菜单是厨师提前定好的,大厨做什么它就只能吃什么。Code Mode 相当于让 Agent 自己进厨房,它不但能切菜、调味、翻锅,还能现场发明一道菜,不满意就倒掉重做。模型在代码里可以定义新函数、写 while 循环、装没装过的库、处理异常,等于把整个编程语言的能力都借给了 Agent。
这也是“范式转移”真正的含义:底层的决策逻辑从“有限枚举 + 匹配”变成了“生成 + 执行 + 反馈验证”;Agent 的技能边界从静态集合变成了动态程序空间;状态管理从“靠模型上下文用文本记录”变成了“解释器进程里真实存在的变量和文件”。前者是辅助人类完成调用,后者是 Agent 在独立解决问题。
2. 工具调用的天花板:那些让我转向 Code Mode 的真实场景
2.1 多维编排与分支逻辑:函数调用的参数组合变复杂就失控
我印象最深的一个项目是“销售周报自动生成”。用工具调用版本,我需要给 Agent 准备read_excel、clean_data、group_by、calculate_change、create_bar_chart、render_html、send_email这一串工具。听上去没什么,但实际跑起来问题很多。
其中一个最磨人的需求是分支:如果销售额环比下降超过 5%,邮件正文要重点提示风险;如果增长超过 10%,要突出亮点;其他情况就平铺直叙。用工具调用做这种分支,模型需要不断在“是先画图还是先判断”“判断完要不要调另一个工具”之间绕圈。有时候它会先调用create_bar_chart再判断,有时候又会把判断结果写错,甚至会出现连环调用参数不匹配,比如把 group 字段和 value 字段写反。
后面我换成了 Code Mode,让模型直接生成一段完整的 Python 脚本,用 pandas 读文件、按if change < -0.05分支处理、用 matplotlib 生成图表、所有中间结果都保存在变量里。执行一次,整个报告逻辑就全部跑完,最后只需调用一次send_email去发送结果。原来同样一个任务用工具调用要七八轮,经常因为某一步参数出错要重试;代码模式基本两三轮就稳定收敛,成功率从七成左右提到了九成五以上。
2.2 数据处理和批量操作:工具接口永远缺一个你需要的形状
还有一类更让人抓狂的任务,就是批量文件操作。比如“把下载目录整理干净”,要按扩展名归拢、给 PDF 文件按创建时间重命名、清掉临时文件。用工具调用的思路,你得先想好有多少种可能的操作,每一种做一个小工具。可实际情况是文件名不规则、扩展名大小写混用、子目录嵌套,模型每处理一个文件都要执行一次工具调用,循环起来极其啰嗦,而且一旦遇到预期外的格式,某个工具就会报错,整个流程直接中断。
Code Mode 解决这个问题的方式非常干净。模型直接写一个os.walk脚本,用for循环遍历目录,按规则os.rename文件,再统一清理临时文件,所有逻辑靠十几行代码就完成了。就算文件名规则千奇百怪,代码也可以处理字符串拼接、正则匹配、异常捕获。
说到底,工具调用适合动作空间小且稳定的任务;一旦任务需要“把很多小动作组合成一个有分支、有循环的流程”,或者要处理大量异构数据,工具调用就会暴露两个天花板:组合爆炸和表达能力不足。Code Mode 恰好在这两个维度上是降维打击。
3. Code Mode 背后的运行机制:代码即动作,反馈即观测
3.1 从 Tool Loop 到 CodeAct 的架构演变
先说传统 Agent 的经典循环:ReAct,即 Thought / Action / Observation。在工具调用范式下,Action 是一个离散动作,比如调用某个 API;Observation 是函数返回值。每走一步,模型都需要把新的观测结果拼接回上下文,再决定下一步动作。这个循环本身没问题,但它的瓶颈在于动作空间被限制成了“有限集合”,而且每一步都要重复携带大量工具定义,上下文很快就会膨胀。
CodeAct 是另一种思路:把代码片段本身当作 Action 空间。这个方向在社区里已经有相对系统的研究,代表性做法是让 Agent 直接输出可执行的代码,放入解释器中运行,运行结果作为观测。和 ReAct 最大的不同在于,代码执行后带来的不只是“返回值”,还包含中间变量、文件变化、标准输出、错误堆栈等丰富信息。Agent 不再通过文本描述去猜测状态,而是直接从执行环境里读取真实状态。
为了更直观,我整理了一张对比表:
| 维度 | 工具调用(Tool Use) | Code Mode(CodeAct) |
|---|---|---|
| 动作空间 | 预定义工具集合,有限且静态 | 任意程序逻辑,理论上无限 |
| 状态管理 | 靠上下文文本记录,跨步骤容易丢失 | 解释器变量和文件系统天然保存 |
| 扩展能力 | 需要开发者新增工具 | Agent 可自行定义函数、脚本 |
| 反馈质量 | 只有函数返回值,信息单一 | stdout、异常、文件变化等多元化 |
| 安全控制 | 天然白名单,单点可控 | 需要额外沙箱隔离 |
| 调试成本 | 工具边界清晰,容易定位 | 可能出现死循环、资源占用过高 |
这张表基本概括了我迁移过程中的所有理由。工具调用不是没有价值,而是它的适用范围应该往“外部副作用”收敛,而不是承担所有任务逻辑。
3.2 解释器、沙箱与状态持久化:一个最小可用的 Code Mode 长什么样
一个可用的 Code Mode 运行时,至少包含四个部分:模型接口、代码提取器、执行器、反馈处理器。
模型接口负责把历史消息发给大模型,并解析返回内容。代码提取器负责从模型的回答里抽取出代码块,一般用 markdown 代码块包裹。执行器是在隔离环境中运行代码的组件,常见选择有 Jupyter Kernel、Pyodide、容器内 Python 进程,它们都需要支持多轮会话中的状态保留。反馈处理器则把执行结果截断、格式化后拼回消息队列,因为错误堆栈和长输出可能非常占 token。
可以用一段伪代码描述主循环:
messages = [ {"role": "system", "content": "你是一个数据分析 Agent,请用 Python 完成用户任务。"} ] max_steps = 5 step = 0 while not task_done and step < max_steps: response = llm_client.chat(messages) code = extract_code_block(response) result = sandbox.execute(code, timeout=30) truncated = truncate_output(result, max_tokens=1500) messages.append(response) messages.append({"role": "tool", "content": truncated}) step += 1重点是truncate_output。很多初学 Code Mode 的人会忽略这一步,把完整 stdout 和 stderr 全部扔回上下文,结果模型被超长堆栈淹没,反而不知道该修哪里。我一般只保留尾部 500 到 1500 个 token,如果有错误,截取错误类型和最后几行堆栈就够了。
4. 驱动范式转移的四个变量:模型、上下文、环境与成本
4.1 代码能力强的模型把“写代码”的门槛打下来了
一个很现实的前提是,Code Mode 的流行是因为模型写代码的能力真的够用了。几年前让模型写一段能直接跑通的 Python 脚本都很危险,现在主流模型在常见任务上生成代码的通过率已经非常高。
为什么代码能力会带来范式转移?因为写代码本来就是大模型预训练阶段吃得最透的任务之一,模型对代码语法的熟悉程度远高于对 JSON Schema 的严格约束。工具调用要求模型输出一个必须完全合法的 JSON 对象,一旦参数类型写错或字段多打一个逗号就失败;代码生成则允许模型按照编程习惯自然续写,不那么容易被格式问题卡住。实际跑下来,我的体感是:模型在 Code Mode 里更容易“自我修正”,因为执行反馈会告诉它哪里报错、哪里数值不对,下一轮它可以精准地改代码,而不是重新猜一个工具参数。
4.2 Token 经济学:Code Mode 在长任务里反而更省
很多朋友第一次听说 Code Mode 时会担心:让模型写代码岂不是更费 token?这里需要澄清一下“Token”到底怎么算。Token 可以理解为模型处理文本的最小计费单位,大致一个英文单词约 1 到 1.5 个 token,一个中文汉字约 1 到 2 个 token。
工具调用模式真正费 token 的地方不在输出,而在输入。系统提示里要一次性包含所有工具描述,10 个工具动辄上千 token,而且每轮循环这些描述都会跟着历史消息一起重新计算。模型每次调用工具还要输出完整的工具名和参数对象,输出也不便宜。反观 Code Mode,系统提示往往很短,只需要说明环境、语言和任务约束,模型输出虽然是一次几十行代码,但执行反馈更短,而且完成同样任务所需的总轮数通常更少。
我做过一个对比:抓取一个分页列表并解析成结构化 JSON。工具调用模式每页要“调用请求工具 → 返回 HTML → 再调用解析工具”,大约需要 8 轮,输入随着历史音量增长,总 token 消耗很高。Code Mode 模式下,模型直接写一个while page < 10的脚本,跑了 1 轮就完成抓取,后续再改 2 轮处理异常字段,总 token 反而省了将近一半。当然这不是绝对规律,如果任务本身只需要一次 API 调用,工具调用肯定最省,但在多步骤、多分支任务中,代码模式通常能靠“减少往返次数”赢回成本。
4.3 沙箱与容器技术的成熟让安全边界成为可能
工具调用之所以让人安心,是因为白名单机制,Agent 最多只能调用你列出来的那些函数。而 Code Mode 允许 Agent 执行任意的系统命令,如果不加限制,风险是显而易见的:它可能去读敏感文件、调外部接口、删掉环境里的数据。
现在这条顾虑正在被基础设施解决。容器技术、WASM、微虚拟机都足够成熟,执行器可以把代码丢到无网络、只读根目录、有时间限制的沙箱里跑。更细的隔离可以做到:文件系统大部分只读,只给/tmp写入权限;网络默认禁止,需要特定域名时通过代理白名单开放;内存和 CPU 都设置上限;执行超过 30 秒就强制终止。
安全从“能不能做”的问题,变成了“要怎么配置”的问题。于是 Code Mode 的落地不再被安全卡住,反而因为执行环境可以像虚拟机一样反复销毁重建,比在宿主机上安装一堆 Agent 依赖脚本更干净。
5. 选型决策:什么任务该用工具调用,什么任务应该直接写代码
5.1 任务的结构化程度与动作空间大小
我现在做 Agent 方案设计时,基本会先问三个问题:
- 这个能力是谁提供的?如果是外部系统自带的 API,比如 Slack、CRM、支付平台,那最合理的方式就是工具调用,因为你绝不能让 Agent 去 SDK 文档里现学现用,风险不可控。
- 任务是否需要组合多个动作、处理中间状态?需要的话,优先考虑 Code Mode。数据清洗、文件整理、复杂计算、生成图表、访问网页并解析,这些本质上是编程任务,硬包成工具反而别扭。
- 这个动作是不是高频、低延迟、必须审计?如果是,工具调用那套“明确函数名 + 参数记录”的机制更合适。
拿实际任务举例:让 Agent“查询 5 个城市的天气并汇总成表格发到群里”,最适合的是四个小工具:get_weather、format_table、send_group_message,让模型轮流调用;但如果你想让 Agent“从 200 个 Excel 文件里找出所有销售额环比下滑超过 10% 的店铺,画一张全国分布图,并生成长图发到群里”,那无论如何都应该让它写 Python 脚本,顶多最后发消息时调用一次群机器人工具。
决策逻辑可以套一个简单框架,列成表格会更清楚:
| 任务特征 | 推荐模式 | 关键原因 |
|---|---|---|
| 外部服务副作用(发消息、CRM 操作、支付) | 工具调用 | 白名单、审计、权限控制 |
| 多步数据处理、文件批量操作、网页解析 | Code Mode | 可组合、有状态、表达能力充足 |
| 固定逻辑的简单查询(查天气、查汇率) | 工具调用 | 低延迟、动作空间极小 |
| 分支判断 + 循环遍历 + 按条件出口上报 | Code Mode + 最终工具调用 | 核心逻辑用代码,外部副作用交给工具 |
| 长任务需要反复尝试修正 | Code Mode | 反馈闭环能支撑模型自愈 |
5.2 混合策略:工具调用负责收敛,代码负责展开
更成熟的方案从来不是二选一,而是让两者各司其职。我的默认系统提示词会这么写:
你可以编写并执行 Python 代码来处理数据、文件、计算和调度逻辑。只有在必须访问外部系统并产生副作用的时候,请调用对应工具,比如发送邮件、推送消息、写入生产数据库。
实现上,可以让 Agent 先尽量用代码完成计算和交互,代码里遇到需要外部副作用的节点时,执行器检测到类似request_tool("send_email", {...})的约定调用,就暂停脚本并把请求转给工具层。这一步很关键:它让 Agent 既能享受代码的自由度,又不会绕过所有安全边界。
我见过一些团队把工具调用当成 Agent 的全部,结果为了一个数据分析任务定义了几十个参数复杂的工具,维护成本极高。也见过另一个极端,让 Agent 在沙箱里用requests直接调生产接口,最后被安全团队叫停。混合策略才是实践里最稳定的形态。
6. 实操落地:用 Rust 搭建一个支持 Code Mode 的最小 Agent 运行时
6.1 为什么选 Rust:性能、隔离与生态
热词里有人提到“基于 Rust 语言 AI Agent”,我确实走的就是这条路线。选择一个语言的背后往往是现实的取舍。Rust 最大的优势不是“写起来酷”,而是它适合做 Agent 运行时这种需要高并发、强稳定性和精细资源控制的底座。
Code Mode 的宿主程序要反复拉起解释器、给沙箱设定资源上限、处理超时后的强杀,这些操作都需要底层系统能力。Rust 可以方便地调用tokio做异步调度、用nix管理进程信号、通过cgroups限制资源。相比之下,Python 做 Agent 业务逻辑很方便,但作为长时间运行的宿主,一旦沙箱里出现失控进程,处理起来会比较别扭。这里我的实际选择是:Rust 写控制层和执行器,Python 只作为被沙箱管理的推理语言。
依赖方面,不需要造轮子。LLM 客户端用 reqwest 就够了,解析函数调用或者代码块可以用 serde_json 和 regex,进程管理用 tokio::process,配置读写用 serde。整个运行时的骨架代码量其实不大。
6.2 关键模块拆解:LLM 客户端、执行器、反馈回路
一个最小运行时的核心流程如下:
// 简化示意,主要用于说明流程 let mut messages = build_system_messages(); for step in 0..MAX_STEPS { let response = llm_client.complete(&messages).await?; let code = extract_code_block(&response); let result = sandbox.execute(&code, Duration::from_secs(30)).await?; let observation = truncate_output(result, 1200); messages.push(response); messages.push(Message::tool(observation)); if is_task_done(&result) { break; } }这里有几个容易忽略的细节。extract_code_block不一定只提取第一个代码块,有可能模型在代码前写了一句话,后面代码后面又跟了备注,如果你的解析器写得太严格,一次正常的回答都可能被判失败。我的做法是把输出按```python切分,提取最后一块,并要求模型如果附带解释,必须在代码块之后停止。
is_task_done的判断也很有讲究。不要只盯着“代码没有报错”就认为任务完成,因为代码可能跑了但结果是错的。简单做法是让模型自己判断,在执行结果后面追加一条系统指令:“如果任务已经完成,请在回复开头使用[DONE]标记;否则继续修改代码。”运行时检测到[DONE]就结束循环。
6.3 遇到过的典型问题:超时、死循环、权限逃逸
再分享几个我真实踩过、后来写进 check list 的坑。
第一个是死循环。模型生成的代码偶尔会出现while True但没有 break 的情况,甚至它会在循环里不断开新进程,把 CPU 打满。这个问题只能靠执行器硬阻断,不能指望模型自觉。我的沙箱统一设置 30 秒超时,超时就直接 SIGKILL,并把“代码执行超时,请检查是否有死循环”作为反馈返回给模型,下一轮它通常会改写法。
第二个是资源占用。之前用过裸 Python 子进程当沙箱,结果一次模型生成的代码里不断 append 数据到列表,很快把内存吃到几百兆,差点影响到宿主。后来我把执行器切到容器或者 cgroups 限额,内存设 512MB,磁盘写入量也做配额,才彻底解决。
第三个是权限逃逸。严格来说这不算模型“恶意”,而是它生成了os.system("rm -rf /tmp/xxx")这种危险命令,如果沙箱只做了名义隔离,可能会影响宿主。所以沙箱必须有只读根文件系统和独立临时目录。另外,网络访问默认关闭,需要联网的任务只允许白名单域名,避免模型在代码里直接偷偷调用外部接口绕过审计。
还有一个容易被忽视的点:状态污染。如果你用 Jupyter Kernel 作为长期执行环境,任务之间必须重置变量,否则上一个任务留下的 DataFrame 会出现在下一个任务的globals()里,模型可能直接误用,产生诡异的错误。我在每次任务结束后都会销毁内核重启,宁可多等一两秒,也要保证状态干净。
7. 我眼里的下一步:Code Mode 会成为 Agent 的默认底座吗
7.1 从“调用工具”到“生成专用工具”再到“自我修改”
Code Mode 并不只是让 Agent 写一次脚本这么简单,它正在打开一个更深的可能性:Agent 可以把写出来的代码封装成新的工具,然后沉淀下来。比如一个 Agent 在数据分析过程中写了一个parse_log函数,任务结束后,它把这个函数保存到工具库,下次遇到类似任务时,就可以直接生成对这个函数的调用,不必重新从零写。
这就把范式转移推向了更具冲击力的一层:过去是开发者定义工具给 Agent 用,现在是 Agent 自己生成工具供以后复用,开发者承担的角色变成“审核人”和“守门员”。如果这个循环跑起来,Agent 的能力边界会随着时间的推移不断扩张,而不再受限于项目初始化时注册的那几个工具。
当然,这里面的治理风险也很明显。Agent 生成的工具没有经过严格的单元测试,可能存在隐式假设、边界漏洞甚至安全风险,不能无脑自动写进工具库。我的个人建议是:工具入库前必须有人工 review 环节,至少要有测试用例覆盖正常和异常输入,最好能跑一遍静态安全扫描。
7.2 部署与可观测性:需要配套的新基建
Code Mode 给运维带来的变化比很多人大。以前监控 Agent 只看 API 调用记录,现在你更需要关心“执行了哪些代码、消耗了多少 CPU/内存、运行了多久、输出了什么”。没有执行轨迹,你根本没法排查一个 Agent 为什么突然表现出奇怪行为。
我的做法是给每条执行记录写审计日志,包含请求 ID、任务 ID、代码哈希、退出码、stdout 尾部、stderr 尾部、运行耗时和 token 消耗。这些日志既用于安全审计,也用于调试。有一次线上 Agent 反复生成访问数据库的代码,但每个 SQL 查询都很慢,查日志才发现它每次都在全表扫描,问题根本不在于 Agent 规划能力,而在于它没有拿到表结构的提示词。如果没有执行日志,这种问题会找半天都找不出来。
部署侧也要重视冷启动问题。每个新会话都从零创建沙箱、安装依赖,慢的时候要等几十秒。主流做法是做一组预热的沙箱池,提前拉好常用 Python 镜像、装好库,会话接入时直接复用。但预热池也要小心状态污染,任务结束后必须销毁内核、清空临时目录,不能把一个任务的脏数据带给下一个用户。
我自己现在的默认策略是:优先让 Agent 用 Code Mode 完成核心任务,工具调用退回到“外部副作用出口”的位置。这不是从哪个框架文档里抄来的标准答案,而是被无数个实际场景教育出来的结论——当一个 Agent 需要在真实世界里解决复杂问题,给它一间安全的“厨房”远比给一份固定的“菜单”更有价值。