← 工具调用

手写 Function Calling 循环和框架托管有什么区别?怎么选?

高频 中等 手写调用循环与框架托管 · 第 1 / 3 问 更新于 2026/09/29
工具调用Function CallingSpring AILangChain4jAgent
本题落地项目AI Agent电商平台导购与运营增长系统

简化版

两者做的是同一件事:带着 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电商平台导购与运营增长系统》,上面这些追问你都会迎刃而解。

本题落地项目地狱锤炼AI Agent电商平台导购与运营增长系统基于商城系统、商品知识库RAG、智能导购Agent、SpringAI、Function Calling,实现AI商品推荐、价格库存优惠工具调用、AI商品问答、评价分析和运营增长报告,覆盖电商核心闭环。SpringbootSpringAIAgentRAGFunction Calling源码+SQL喂饭学习教程配套面试文档环境安装文档项目运行文档 学习这个项目 也可以学AI Agent旅游行程智能规划平台地狱锤炼 查看项目