手写 Function Calling 循环和框架托管有什么区别?怎么选?
简化版
两者做的是同一件事:带着 tools 请求模型 → 模型返回 tool_calls → 本地执行工具 → 把结果作为 tool 消息回填 → 再请求,直到模型不再调用工具。区别在于这个循环写在谁的代码里。手写循环要自己维护消息历史(助手消息连同 tool_calls 原样回填)、按工具名分发执行、控制轮数和结束条件,代码多,但每一步都在自己手里,方便在每次调用前后记录步骤、插入校验、用特定工具提前结束。框架托管(Spring AI 的 ChatClient.tools()、LangChain4j 的 AiServices)只要声明工具,一次调用就跑完整个循环,代码少,但循环对业务是黑盒,记录和拦截要借助框架提供的扩展点。需要逐步可控、逐步留痕时手写,工具简单、只要结果时托管。
详细版
一次完整的工具调用循环(两种方式都要做这些事):
messages = [system, user]
loop:
response = 模型(messages, tools)
messages += response(助手消息,含 tool_calls)
if 没有 tool_calls: 结束,response.content 是最终回答
for 每个 tool_call:
result = 执行(tool_call.name, tool_call.arguments)
messages += {role: tool, tool_call_id, content: result}
| 维度 | 手写循环 | 框架托管 |
|---|---|---|
| 代码量 | 多:消息历史、分发、结束条件都自己写 | 少:声明工具,一次调用 |
| 每一步是否可见 | 完全可见,调用前后都能插逻辑 | 黑盒,要靠工具方法内部或监听器 |
| 轮数上限 | 自己写 for 循环 | 框架参数(如最大往返轮数) |
| 结束条件 | 可以自定义(如某个提交工具被调用就停) | 模型不再调用工具时结束 |
| 工具定义 | 自己拼 JSON Schema | 注解或工具对象自动生成 |
| 协议细节 | 自己处理(tool_call_id、空 content) | 框架处理 |
| 适合 | 逐步留痕、逐步校验、自定义终止 | 工具简单、只关心最终结果 |
完整版教学
一、循环本身是协议规定的
Function Calling 协议只规定了一次往返:请求带 tools,响应可能带 tool_calls,工具结果以 role=tool 的消息回传。**「多次往返直到结束」这个循环,协议并不管,是应用自己写的。**所以无论用不用框架,这个循环都存在,区别只是写在应用代码里,还是写在框架代码里。
理解这一点,就能理解框架的所有「限制」:框架替你写了循环,也就替你决定了循环里能插什么、不能插什么。Agent 的 ReAct 循环就是在这个工具循环之上加了「思考—行动—观察」的约定,见「ReAct 与 Plan-and-Execute 有什么区别?Agent 如何选择规划方式?」。
二、手写循环要处理的细节
手写循环的骨架很短,但有几个细节不处理就会出错:
1. 助手消息必须原样回填,包括 tool_calls
下一轮的 tool 消息要用 tool_call_id 对上它,助手消息缺了 tool_calls,
模型服务会认为这个 tool_call_id 无从关联而报错
2. 一轮可能有多个 tool_calls
要逐个执行,每个结果单独作为一条 tool 消息回填,tool_call_id 一一对应
3. arguments 是字符串
模型返回的参数是 JSON 字符串,要自己解析,解析失败要有处理
4. 结束条件和上限
模型不再调用工具时结束;还要有轮数上限,防止无限循环
易错点:只回填工具结果、不回填带 tool_calls 的助手消息,下一轮请求就会因为 tool_call_id 找不到对应的调用而失败。
三、框架托管替你做了什么
以 Spring AI 为例,声明带 @Tool 注解的方法,把工具对象交给 ChatClient:
String answer = chatClient.prompt()
.user(prompt)
.tools(toolService) // 按 @Tool 注解生成 tools 定义
.call() // 内部跑完整个循环
.content(); // 只拿到最终回答
一次 call() 里,框架完成了:生成工具 Schema、发请求、解析 tool_calls、反射调用方法、把返回值作为 tool 消息回填、再请求,直到模型不再调用工具。LangChain4j 的 AiServices 也一样:@Tool 方法转成「工具规格 + 执行器」,框架负责往返。
代价是:业务代码只看到一次调用和最终文本,中间调了几次、调了什么、每次耗时多少,默认都看不到。
四、选型看你需要在循环里做什么
| 需求 | 手写 | 托管 |
|---|---|---|
| 每次工具调用前后写一条步骤记录 | 直接写 | 只能放在工具方法内部 |
| 某个工具被调用就立即结束循环 | 直接判断 | 需要额外机制 |
| 每一轮都检查预算、次数 | 直接写 | 依赖框架参数或监听器 |
| 工具少、只要最终回答 | 显得冗长 | 最合适 |
| 快速接入、减少样板代码 | 慢 | 快 |
一个实用的判断:如果你要在界面上展示「Agent 这一次做了哪几步」,或者要用某个工具的调用作为结束信号,手写更直接;如果工具只是给模型查数据用、没有过程展示需求,托管更省事。
五、用「提交工具」自定义结束条件
手写循环的一个常用技巧:定义一个专门的提交工具,让模型用它交出结构化结果:
{"name": "submit_recommendations",
"parameters": {"items": [{"productId": 12, "reason": "..."}]}}
循环里一旦检测到这个工具被调用,就记下参数、结束循环。相比「模型不再调用工具时结束、再从最终文本里解析结果」,提交工具的参数本身就是结构化的,不用再解析自由文本;而且结束时机明确,不会出现模型说完结论又继续调工具的情况。提交的内容仍然要校验:模型给的是 ID 和理由,价格、库存这类事实要用真实工具重新查一次再落库。
六、托管也能拿回可观测性
选了托管,并不意味着放弃观测,只是观测点换了位置:
步骤记录:放进每个工具方法的统一外壳,开头写「运行中」、返回前回填结果和耗时
轮数统计:给模型挂监听器,每次往返回调一次,累计轮数和 token
轮数拦截:用框架的最大往返轮数参数
调用次数拦截:在工具方法开头累计判断,超限抛专门的中断异常
这部分的具体做法见「框架托管的工具循环是黑盒,怎么记录每一步、怎么限制轮数?」。
七、两种方式可以并存
同一个项目里完全可以两种都用:普通的问答、分析调用用框架(不用再拼消息数组、鉴权头、解析返回),需要逐步留痕的 Agent 循环手写。例如一次导购 Agent 最多 10 轮,每轮可能调多个工具,每次调用都要落一条步骤记录给前端时间线展示,这类场景手写的收益最明显。
八、常见误区与追问
- 误区:用了框架就不需要理解 tool_calls 协议。 框架出问题时(参数解析失败、工具名对不上)要靠协议知识排查,手写一遍才知道框架替你做了什么。
- 误区:手写循环只要回填工具结果就行。 带 tool_calls 的助手消息也必须原样回填,否则 tool_call_id 无法关联。
- 误区:框架托管就没法记录每一步。 可以在工具方法内部和模型监听器里记录,只是位置不同。
- 误区:模型不再调工具时的最终文本就是结果。 结构化结果最好用提交工具交出来,并在落库前校验事实字段。
- 误区:两种方式只能选一种。 同一项目里可以普通调用用框架、Agent 循环手写。
- 追问:手写循环的轮数上限设多少? 按任务需要的工具调用次数估算,留出余量;上限触发时要记录原因,而不是静默返回半截结果。
- 追问:一轮返回多个 tool_calls 怎么处理? 逐个执行、逐个回填,每条 tool 消息用各自的 tool_call_id 对应。
九、加强记忆
工具调用的多轮循环不是协议规定的,而是应用写的;手写和托管的区别只是循环在谁的代码里。手写要回填带 tool_calls 的助手消息、逐个执行并用 tool_call_id 回填结果、解析字符串参数、设轮数上限,好处是每一步可见、可以用提交工具自定义结束。托管声明工具一次调用跑完,代码少但过程是黑盒,观测要放进工具方法和监听器。需要逐步留痕和自定义终止就手写,只要结果就托管,两者可以在同一项目并存。
项目实战落地
项目里怎么做的
《AI Agent电商平台导购与运营增长系统》在同一个项目里两种都用:商品问答、评价分析、运营报告走 Spring AI 的 ChatClient;智能导购 Agent 的循环 executeTask() 是手写的:
- 自己组装工具和消息:
buildAgentTools()把检索候选商品、查价格、查库存、查优惠、读用户画像、提交推荐 6 个工具拼成 OpenAI 兼容的tools数组,初始对话是 system 加 user 两条; - 每轮回填助手消息:
chatCompletion(messages, tools)拿到助手消息后原样加回messages(content为空时补成空串),没有tool_calls就结束; - 逐个执行、逐个回填:每个
tool_call解析参数后按工具名分发到真实工具服务,调用前后各写一次agent_step,结果以role=tool、带tool_call_id的消息回填; - 提交工具结束循环:模型调用
submit_recommendations时记下商品 ID 和理由、结束循环;最多MAX_AGENT_ITERATIONS = 10轮; - 落库前重查:
materializeRecommendations用真实工具重新查价格、库存、优惠,最多落 3 条推荐。
《AI Agent旅游行程智能规划平台》选了托管:8 个 @Tool 方法交给 LangChain4j 的 AiServices,排一版行程就是一次 chatWithTools 调用,步骤记录写在每个工具方法的 runTool 外壳里。
为什么这样取舍
- 导购 Agent 手写:循环需要在每一次工具调用前后写
agent_step,框架默认接管整个循环、把这些细节包在内部,保留手写也能完整展示原始报文。 - 旅游 Agent 托管:一版行程要几十轮往返,外层还要套反思循环,内层交给框架能少写大量样板代码,步骤记录放进工具外壳就够了。
面试官还会追问
- 模型提交的推荐商品落库时发现没有库存了怎么处理?如果全部被剔除呢?
- 模型在推荐理由里把价格写错了,会不会污染推荐明细里的价格?
学完《AI Agent电商平台导购与运营增长系统》,上面这些追问你都会迎刃而解。