跳到主要内容

智能体循环与出口处理:原生工具调用如何迭代

30 秒导读: 前面几章把"能力"装好了(记忆 / 联网 / RAG / 工具),这一章讲它们怎么真正跑起来。 当模型开始流式吐字,出口管线一边把碎片攒成结构化的 output 列表,一边盯着有没有 tool_calls; 一旦有,就逐个执行工具 → 把结果当成 tool 消息塞回对话 → 再调用一次模型,这样一圈一圈转, 直到模型不再要工具、只给最终答复为止。这个"转圈"就是 Open WebUI 的智能体内核。

本章聚焦原生工具调用(native function calling) 的迭代循环。工具从哪来、非原生的选择器模式, 见 工具系统;一条请求怎么走到这里,见 请求生命周期


1. 这章讲什么(先建立直觉)

先厘清一个常见误解:模型自己不会执行任何工具。它只会在流里说一句"我想调用 search_web,参数是 {...}"。 真正把这句话落地执行、拿到结果、再喂回去让它继续想的,是宿主程序——在 Open WebUI 里,就是这一章讲的出口管线。

所以"智能体"不是一个魔法组件,而是一个朴素的循环:

模型流式输出 ──► 攒成 output 列表 ──► 有 tool_calls 吗?

┌───── 有 ──┴── 没有 ─────┐
▼ ▼
逐个执行工具 收尾:标完成
│ 跑 outlet filter
结果转成 tool 消息 跑后台任务(标题/标签)
│ │
再调用一次模型 ◄──(回到顶部) 结束

这一圈里有四件核心的事,本章按顺序讲:

步骤干什么主要函数
① 累积把流式 delta 碎片攒成 output 列表 + tool_callsstream_body_handler(middleware.py:3921)
② 执行逐个跑工具、把结果规整成字符串process_tool_result(middleware.py:995)
③ 回灌结果作为 tool 消息塞回,再调用模型convert_output_to_messages + generate_chat_completion(middleware.py:48414877)
④ 收尾标完成、跑 outlet filter、跑后台后处理outlet_filter_handler(3274)、background_tasks_handler(3060)

全部逻辑集中在一个文件:backend/open_webui/utils/middleware.py。入口是 process_chat_response


2. 出口全景:两个入口 + 一个循环

出口只有一个门:process_chat_response(response, ctx)(middleware.py:5259)。它先分流:

process_chat_response (5259)

├─ 非 StreamingResponse ─────► non_streaming_chat_response_handler (3424)

├─ 不是 event-stream/ndjson ─► 原样返回(非标准响应)

└─ 流式响应 ─────────────────► streaming_chat_response_handler (3571) ← 本章主角

本章只讲流式路径,因为智能体循环几乎全在 streaming_chat_response_handler 里(middleware.py:3571)。 它内部又套了一个 response_handler(3608),作为后台任务运行;真正逐行读流的是嵌套函数 stream_body_handler(3921)。

关键结构:stream_body_handler 只负责"读完一次响应",把这次响应里的文本、推理、工具调用都攒好。 外层用一个 while tool_calls(middleware.py:4534)反复:每转一圈就执行一批工具、再调用模型、再喂给 stream_body_handler 读下一次响应。"读一次响应"和"转一圈"是两个不同的循环层级,别混。

response_handler (3608)
└─ stream_body_handler(第 1 次响应) ← 读初始流,攒 output + tool_calls

└─ while tool_calls: ← 智能体循环(4534)
执行这一批工具
结果 → function_call_output 塞进 output
convert_output_to_messages → new_form_data
generate_chat_completion(...) ← 再调用模型(4877)
stream_body_handler(下一次响应) ← 读新流,可能又攒出 tool_calls
循环直到 tool_calls 空 或 触顶

└─ 收尾:标完成 → outlet_filter_handler → background_tasks_handler

3. 累积:从流式 delta 到 output 列表

3.1 output 列表是什么

Open WebUI 内部用一个与 OpenAI Responses API 对齐的 output 列表表示一条助手消息的全部内容, 而不是一根扁平字符串。列表里每个元素是一个"item",有几种类型:

item 类型代表什么
message一段助手可见文本(content 里是 output_text 片段)
reasoning一段思维链(<think> 之类)
function_call模型发起的一次工具调用(名字 + 参数)
function_call_output该工具的执行结果(回灌用)
open_webui:code_interpreter代码解释器块

之所以用结构化列表,是因为一条助手消息里可能穿插着"想一段 → 调个工具 → 再说一段 → 再调工具", 扁平字符串表达不了这种交错。output 列表按时间顺序把它们一节一节码起来。

3.2 三个 tool_calls 的来源

模型说"我要调工具"这件事,在流里有三种不同表达,stream_body_handler 分别处理:

流式事件

├─ data.type 以 "response." 开头 ──► Responses API 事件
│ handle_responses_streaming_event (602)
│ 累进 output,function_call item 直接落进列表

├─ 普通 Chat Completions chunk ──► delta.get('tool_calls') (4143)
│ 按 index 累积到 response_tool_calls (3928)

└─ 文本里夹了 <think>/<code_interpreter> 等标签 ──► 旧式 XML 标签路径
tag_output_handler(内嵌于 3608) 就地切成 item

(a) Chat Completions 的 delta.tool_calls(最主流)。 流里每个 chunk 的 delta 可能带 tool_calls(middleware.py:4143,delta_tool_calls = delta.get('tool_calls', None))。一次工具调用的 名字和参数是分很多 chunk 挤牙膏发过来的,所以要按 index 攒:同一个 index 的 arguments 字符串 不断拼接(middleware.py:4172-4175)。攒的容器是本次响应专属的 response_tool_calls(middleware.py:3928)。

这段就是"边攒边拼"的核心,极短:

# middleware.py:4169-4175 —— 同一个工具调用的碎片按 index 拼起来
if delta_name:
current_response_tool_call['function']['name'] = delta_name
if delta_arguments:
current_response_tool_call['function']['arguments'] += delta_arguments

重点看:arguments字符串累加,因为 JSON 是被切碎发来的,得等发完才能解析。

(b) Responses API 事件。 若上游是 OpenAI Responses API,事件形如 response.output_item.addedresponse.function_call_arguments.deltaresponse.completed 等。这些交给 handle_responses_streaming_event(data, current_output)(middleware.py:602)——它是纯函数: 吃进当前 output、吐出新的 output(不原地改,靠浅拷贝逐层复制,见 middleware.py:619-620 的注释), 把 function_call item 直接累进 output 列表。等这次响应读完,再从 output 里把还没执行的 function_call 收集成 tool_calls(middleware.py:4480-4506)。

(c) 旧式 XML 标签路径。 有些模型不走原生 FC,而是在普通文本里<think>...</think><code_interpreter>...</code_interpreter> 这类标签。内嵌函数 tag_output_handler(定义于 3608 内) 边流边扫,检测到起始标签就把当前 message item 切开、新起一个 reasoning / code_interpreter item。 注意 middleware.py:3876-3894 的注释与代码:代码解释器这条旧路径,复刻了原生 FC 的五道授权门 (feature 开关、模型 builtinTools 设置、全局开关、模型 capability、用户权限),防止绕过鉴权。

3.3 一个工具调用可能藏了多个 —— _split_tool_calls

有些模型(注释举例 GPT-5.4)会在同一个 tool call index 下塞多个背靠背的 JSON 对象,拼出来是非法 JSON:

'{"query":"A","count":5}{"query":"B","count":5}'

_split_tool_calls(tool_calls)(middleware.py:157)用 json.JSONDecoder().raw_decode 一段一段解, 把这种黏在一起的参数拆成多个独立工具调用,各自分配新 id,好让它们分别执行;单对象则原样透传。

本次响应读完后,攒好的 response_tool_calls 会经 _split_tool_calls 处理再压进外层的 tool_calls 队列(middleware.py:4477;Responses API 路径见 4506)——注意 tool_calls 是个队列,每次响应 push 一批,外层循环每转一圈 pop(0) 一批。


4. 执行:逐个跑工具、处理结果

外层 while tool_calls(middleware.py:4534)每转一圈,先 pop(0) 取出一批工具调用(4540), 给每个在 output 里补一个 function_call item(4549),然后逐个执行(for tool_call in ...,4574)。

4.1 解析参数:先 ast,后 json

模型生成的参数未必是合法 JSON(可能是 Python 字面量风格)。所以先试 ast.literal_eval(middleware.py:4583),失败再退回 json.loads(4588);两者都崩就把这次调用记成 一条错误结果、continue(4590-4597)。解析成功后,还会把参数规范化回合法 JSON 字符串 (middleware.py:4601),供下游 LLM 集成使用。

执行前有一道安全裁剪:按工具 spec 的 parameters.properties 只保留声明过的参数键 (middleware.py:4616-4620),丢掉模型多塞的字段。

4.2 两种执行方式

tool_function_name 在 tools 里?

├─ direct_tool(客户端工具) ──► event_caller({'type':'execute:tool', ...}) (4622)
│ 让前端执行,结果回传

└─ 服务端工具 ──────────────► tool['callable'](**params) (4645)
先 get_updated_tool_function 注入 __messages__/__files__

direct=True 的工具(浏览器侧)通过 event_callerexecute:tool 事件让前端跑 (middleware.py:4622-4634);普通服务端工具直接 await tool_function(**params)(4645)。 工具不存在则返回 Error: Tool "..." not found.(4650);执行抛异常就把异常字符串当结果(4648)。

4.3 结果规整:process_tool_result

拿到的原始结果五花八门(字符串、dict、list、HTMLResponse(data, headers) 元组、MCP 的多模态数组、 base64 图片……)。process_tool_result(middleware.py:995)把它们统一收敛成三样:

process_tool_result(...) ──► (tool_result:str, tool_result_files:list, tool_result_embeds:list)
输入形态处理
HTMLResponseContent-Disposition: inlinebody 进 embeds(前端 iframe 嵌入),结果文本换成"UI 已展示"占位(1014-1046)
外部/action/terminal 工具的 (data, headers) 元组按 header 判断 inline HTML / Location 嵌入(1050-1109)
data:image/... 开头的字符串files,文本换成"图片已读取"(1116-1118)
MCP 的 list(text/image/audio/resource)text 拼进结果、图音进 files(1120-1161)
剩下的 dict/listjson.dumps 成字符串(1176-1177)

最后有一道保险:确保 tool_result 一定是字符串或 None(middleware.py:1182-1187),避免回灌时拼接崩溃。

4.4 副产物:终端事件 与 引用来源

执行完还挂两个副处理:

  • 终端可视化。 terminal_event_handler(middleware.py:1192,调用点 4662)针对"Open Terminal"类工具 (display_file / write_file / run_command)发 terminal:* 事件,让前端刷新文件预览。

  • 结果转引用(citation)。 若工具属于 search_web / fetch_url / view_file / view_knowledge_file / query_knowledge_files 且开启了引用(middleware.py:4670-4681),用 get_citation_source_from_tool_result(middleware.py:206)把结果解析成 source/document/metadata 三段式的来源字典。它按工具名分支:search_web 解析成 [{title,link,snippet}] 列表(236)、 fetch_url 取前 500 字符做摘要(289)、query_knowledge_files 按来源分组(308),其余走兜底(353)。 这些来源既发给前端显示(4753),又会在下一轮作为引用标记注入对话(见 §5.3)。

每个工具执行完,结果连同 files/embeds 压进 results(middleware.py:4693-4700)。


5. 回灌与再调用:循环怎么闭合

这是"智能体"真正成立的地方——把工具结果变成模型能读的下一轮输入

5.1 结果落成 function_call_output item

一批工具都跑完后,先把对应的 function_call 标成 completed(middleware.py:4703-4711),再把每条结果 落成一个 function_call_output item 追加进 output(middleware.py:4727)。这里有个巧妙的双通道设计:

  • 图片 data URI → 作为 input_image part,只给 LLM 看(middleware.py:4720-4722);
  • 其它文件(MCP 图/音)→ 作为 display_files,只给前端显示(4723-4725)。

发给前端时还会把体积巨大的 input_image base64 从 output 里剥掉(middleware.py:4806-4812), 避免把大 data URI 推给浏览器。

5.2 output → messages,再调用模型

回灌的关键转换是 convert_output_to_messages(misc.py:185,调用点 middleware.py:4841):它把结构化的 output 列表重建成 OpenAI Chat Completions 格式的 messages——function_call 变成 assistant 消息里的 tool_calls 数组,function_call_output 变成 role: 'tool' 的消息。

拼好新 messages 后,generate_chat_completion(...)(middleware.py:4877)再调用一次模型。返回若仍是 StreamingResponse,就交给 stream_body_handler 读下一次响应(middleware.py:4904);否则跳出循环 (4907-4908)。两条分支按上游能力走:

ENABLE_RESPONSES_API_STATEFUL 且有 last_response_id?

├─ 是 ──► 只发 system + 本轮 output 转的 messages,附 previous_response_id (4832-4839)
│ (服务端有状态,无需重发全历史)

└─ 否 ──► form_data.messages 尾部拼上 tool_messages,整段重发 (4841-4861)
(Chat Completions 无状态;多模态 tool 消息里的图片会被抽到一条 user 消息 4863-4875)

5.3 跨轮的状态保存:pre-RAG 快照

一个容易踩的坑:如果第一次装配时把 RAG 模板(检索出的 <source> 上下文)烤进了用户/系统消息, 那循环里每再调用一次,就会重复叠加一遍模板。Open WebUI 的解法是在装配阶段先存一份"注入 RAG 之前" 的原始快照——见 process_chat_payloadmiddleware.py:2896-2902 的注释与代码:

# middleware.py:2899-2902 —— 存下 RAG 注入前的真·原始状态
metadata['system_prompt'] = get_content_from_message(system_message) if system_message else None
metadata['user_prompt'] = get_last_user_message(form_data['messages'])
metadata['sources'] = sources[:] if sources else []

循环里每轮要重新拼引用上下文时,先用这份快照还原用户消息与系统消息(middleware.py:4762-4770), 再把"文件来源(带正文)+ 工具来源(仅引用标记,include_content=False)"重新拼成一段 RAG 上下文注入(middleware.py:4772-4800)。这样无论转多少圈,模板都只有一份、不叠加。

5.4 循环的边界:迭代上限

循环条件是 while tool_calls and (上限为空 or 迭代数 < 上限)(middleware.py:4534)。上限来自 CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS(env.py:888,默认 256,设为 -1 表示无限=None)。触顶时 写一条错误、发 chat:message:error 事件(middleware.py:4913-4931),防止模型和工具无限对打烧钱。

另有一个独立的小循环给代码解释器:while output[-1] 是 code_interpreter(middleware.py:4937), 最多重试 MAX_RETRIES = 5 次,把代码块交给 pyodide 或 jupyter 执行、结果回填再问模型(4979-5097)。


6. 前端可视化:<details> 标签怎么渲染

循环跑的同时,前端要实时看到"正在执行工具 / 已执行 / 推理中"。做法是把 output 列表序列化成一段 带 <details> 标签的 HTML/markdown,持续通过 chat:completion 事件推给前端。核心是 serialize_output(output)(middleware.py:442)。

它按 item 类型逐个渲染:message 出正文;function_call 配上对应的 function_call_output,渲染成 一个 <details type="tool_calls" done="true" ...> 块(middleware.py:482-484);还没有结果时渲染 done="false" 的"Executing..."(486-488);reasoning / code_interpreter 各有自己的 <details> (499533)。

工具还在挤牙膏、结果没出来时,循环也会预造一批 function_call item 先渲染出来 (middleware.py:4183-4205,pending_fc_items),让用户立刻看到"它准备调这个工具了"。

OpenAI Responses API 的服务端内置工具(web_search / file_search / computer_use)另有专门渲染: _render_openai_tool_call_handler(item, done)(middleware.py:396)按类型拼一句摘要(如 Search: <query>),包成 <details type="tool_calls">(437-439),由 serialize_outputmiddleware.py:494-497 调用。

展示这些 <details> 块时不含三反引号,但 serialize_output 里 code_interpreter 分支会生成 ```{lang} 代码围栏(middleware.py:557)——这是给前端 CodeBlock.svelte 渲染用的,不是本文的展示块。


7. 收尾:标完成 → outlet filter → 后台后处理

循环彻底结束(模型不再要工具)后,进入收尾三步:

① 标完成 + 落库。 把所有 in_progress 的 item 翻成 completed(middleware.py:5104-5107), 拼出带 done: True 的最终 data(5114-5120),写库并发最后一个 chat:completion 事件(5122-5169)。

② outlet filter。 outlet_filter_handler(ctx)(middleware.py:3274,调用点 5176)在同进程内 跑出口过滤器(取代了过去单独的 POST /api/chat/completed 往返)。它先跑 pipeline outlet (3361),再跑函数式 outlet filter(3378)。若过滤器改了 output,会从 output 重新派生 content (serialize_output,3402)、落库,并发 chat:outlet 事件让前端同步(3413-3419)。插件式 filter 的 细节见 插件框架

③ 后台后处理。 background_tasks_handler(ctx)(middleware.py:3060,调用点 5177)做与主答复无关的 "善后":

任务干什么位置
FOLLOW_UP_GENERATION生成"追问建议",发 chat:message:follow_ups3121-3170
TITLE_GENERATION给会话起标题,发 chat:title3175-3235
TAGS_GENERATION给会话打标签,发 chat:tags3237-3271

注意它会先把消息里的 <details> 标签和图片用正则剥干净(middleware.py:3097-3103)再喂给这些 小模型任务——因为标题/标签只关心纯文本,不需要工具可视化的噪声。

收尾还处理取消:若任务被 CancelledError 打断,会 aclose 上游流、asyncio.shield 保存已生成的 部分状态再重抛(middleware.py:5178-5215),避免流被孤立、连接泄漏。


8. 边界与坑(诚实说明)

  • 模型不执行工具,宿主执行。 整章的循环都是宿主在做;模型只"请求"。理解这点才不会误读日志。
  • 两层循环别混。 stream_body_handler = 读一次响应;while tool_calls = 转一圈(执行+再调用)。 一次响应可能产出多批工具调用(队列),一圈只 pop(0) 一批。
  • 迭代有硬上限。 默认 256 圈(CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS),触顶直接报错停,不会无限转。
  • 参数解析是"尽力而为"。ast.literal_evaljson.loads,都失败才报错——为兼容不产出严格 JSON 的模型。
  • RAG 模板靠 pre-RAG 快照防重复。 循环里每轮都从 metadata['system_prompt']/user_prompt/sources 快照还原再重注入;没有这份快照,多轮会把检索上下文叠加多份。
  • 状态保存分有/无状态两条路。 Responses API 有状态时只发增量 + previous_response_id;Chat Completions 无状态时每轮重发全历史。
  • 看不出的地方: 本章不覆盖工具从哪来、以及非原生的"选择器"函数调用模式——那属于 工具系统

9. 代码地图(导航索引)

全部符号位于 backend/open_webui/utils/middleware.py(除标注外)。

主题文件路径符号名
出口总入口 / 分流backend/open_webui/utils/middleware.py:5259process_chat_response
流式出口主体(智能体循环所在)backend/open_webui/utils/middleware.py:3571streaming_chat_response_handler
后台任务包装backend/open_webui/utils/middleware.py:3608response_handler
读一次流式响应、攒 outputbackend/open_webui/utils/middleware.py:3921stream_body_handler
本次响应的 tool_calls 累积容器backend/open_webui/utils/middleware.py:3928response_tool_calls
Chat Completions delta 累积backend/open_webui/utils/middleware.py:4143delta.get('tool_calls')
旧式 XML 标签就地切块backend/open_webui/utils/middleware.py:3609tag_output_handler
拆黏连的多 JSON 参数backend/open_webui/utils/middleware.py:157_split_tool_calls
Responses API 事件累进backend/open_webui/utils/middleware.py:602handle_responses_streaming_event
智能体主循环backend/open_webui/utils/middleware.py:4534while tool_calls
工具结果规整为字符串+文件+嵌入backend/open_webui/utils/middleware.py:995process_tool_result
结果转引用来源backend/open_webui/utils/middleware.py:206get_citation_source_from_tool_result
终端事件可视化backend/open_webui/utils/middleware.py:1192terminal_event_handler
结果落成回灌 itembackend/open_webui/utils/middleware.py:4727function_call_output
output → Chat Completions messagesbackend/open_webui/utils/misc.py:185convert_output_to_messages
再调用模型backend/open_webui/utils/middleware.py:4877generate_chat_completion
pre-RAG 快照保存backend/open_webui/utils/middleware.py:2896metadata['system_prompt']
output 序列化为 details HTMLbackend/open_webui/utils/middleware.py:442serialize_output
OpenAI 内置工具渲染backend/open_webui/utils/middleware.py:396_render_openai_tool_call_handler
出口过滤器收尾backend/open_webui/utils/middleware.py:3274outlet_filter_handler
标题/标签/追问后处理backend/open_webui/utils/middleware.py:3060background_tasks_handler
迭代上限常量backend/open_webui/env.py:888CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS
Responses API 有状态开关backend/open_webui/env.py:910ENABLE_RESPONSES_API_STATEFUL

相关章节: 请求生命周期 · 入口装配 · 工具系统 · 插件框架 · 全景与阅读地图