TECH NOTE
2638 字 13 分钟 系列 13 / 22附录:Subagents 为什么是 4 次模型调用——抓包实录
ON THIS PAGE / 目录13 节
一句话结论:Subagents 的 4 次 = 主管派活(1)+ 子智能体干活(它自己也要”决定调工具→拿结果再答”,2 次)+ 主管把结果说成人话(1)。第 4 次不是多余的,它是”工具结果必须由主管变成用户可读的回答”这一步——也就是官方文档说的”集中管控的过路费”。
本篇是
03 性能对比里「Subagents:4 次调用」的实证附录 | 实测日期:2026-08-30 | 模型:DeepSeek(deepseek-v4-flash-vision-exp,接口回报deepseek-flash)| 协议:OpenAI Responses API(/v1/responses)复现实验说明:
demo.py/capture.py/summarize.py/captured.jsonl
一、这次抓包到底跑了什么
一个最小的 Subagents 系统(实验脚本为 demo/demo.py,运行方式见复现实验说明,只有 3 个部件):
| 部件 | 代码 | 手里有什么 |
|---|---|---|
| 主管 Supervisor | create_agent(model, tools=[call_calendar_agent], system_prompt="你是个人助理主管…") | 只有 1 个工具 schedule_event(它就是子智能体的包装壳) |
| 子智能体 Calendar Agent | create_agent(model, tools=[create_calendar_event], system_prompt="你是日历助手…") | 只有 1 个工具 create_calendar_event(桩函数,代替真实日历 API) |
| 包装工具 | @tool("schedule_event") → calendar_agent.invoke(...),只返回 messages[-1] | —— |
用户输入一句话:
帮我约明天下午 3 点和张三开产品评审会,1 小时二、4 次调用的时序(每条都标注了抓包序号与耗时)
用户请求 │ │ ① 11:35:03 主管第一次调模型(in=347 tokens) │ 上下文 = [主管的 system prompt, 用户原话] │ 产出 → function_call: schedule_event({"request":"…时长 1 小时"}) ▼┌─ 工具 schedule_event 被调用(进程内,不是 HTTP):主管把活转给日历子智能体│ ││ │ ② 11:35:06 日历子智能体调模型(in=398 tokens)│ │ 上下文 = [日历助手的 system prompt, 主管转交的那句话] ← 注意:看不到主对话历史│ │ 产出 → function_call: create_calendar_event({"title":"产品评审会", …})│ ▼│ ┌─ 工具 create_calendar_event 执行:返回 "OK: created '产品评审会' at 2025-06-11T15:00:00 for 60 min"│ │ ││ │ │ ③ 11:35:07 日历子智能体再调模型(in=1020 tokens)│ │ │ 上下文 = [system, 主管转交的话, 它自己的 function_call, 工具结果]│ │ │ 产出 → 文本:"已为你创建「产品评审会」,时间:明天(2025-06-11)15:00,时长 1 小时。"│ │ └─ 这句文本作为 schedule_event 的返回值,回到主管的消息列表里│ └───┘││ ④ 11:35:08 主管第二次调模型(in=462 tokens) ← 这就是你问的"第 4 次"│ 上下文 = [主管 system prompt, 用户原话, ①的 function_call, ③返回的工具结果]│ 产出 → 面向用户的最终回答:"已经帮你安排好了:明天(2025-06-11)下午 3 点和张三的产品评审会,时长 1 小时。"▼最终回复三、4 次模型调用的账(来自 captured.jsonl 的真实数字)
| # | 时间 | 谁在调 | 请求上下文里有什么 | 产出 | in | out | 其中思考 |
|---|---|---|---|---|---|---|---|
| ① | 11:35:03 | 主管 | system(主管) + 用户原话 | function_call: schedule_event | 347 | 75 | 18 |
| ② | 11:35:06 | 子智能体 | system(日历) + 转交的请求 | function_call: create_calendar_event | 398 | 584 | 494 |
| ③ | 11:35:07 | 子智能体 | 上面 4 项 + 它自己的工具调用 + 工具结果 | 文本(给主管看) | 1020 | 30 | 0 |
| ④ | 11:35:08 | 主管 | system(主管) + 用户原话 + ①的调用 + ③的结果 | 文本(给用户看) | 462 | 31 | 0 |
| 合计 | 2227 | 720 | 512 |
端到端耗时约 5 秒(4 次调用串行:1 次主管决策 + 2 次子智能体 + 1 次主管收尾)。
四、原始报文(Responses API,节选真实内容)
① 主管第一次调用:请求
{ "model": "deepseek-v4-flash-vision-exp", "input": [ {"type": "message", "role": "system", "content": "你是个人助理主管。遇到日程类请求时,调用 schedule_event 交给日历助手,拿到结果后用一句自然语言回复用户。"}, {"type": "message", "role": "user", "content": "帮我约明天下午 3 点和张三开产品评审会,1 小时"} ], "tools": [ {"type": "function", "name": "schedule_event", "description": "把用户的日程安排请求交给日历助手处理。入参 request 应是完整的一句话请求。", "parameters": {"type": "object", "properties": {"request": {"type": "string"}}, "required": ["request"]}} ], "max_output_tokens": 2048}注意:tools 里只有 schedule_event——主管根本不知道 create_calendar_event 的存在。这就是”子智能体即工具”的报文级证据。
① 主管第一次调用:响应
{ "id": "713dda7a-2723-4f52-b22a-26483d8ee7e9", "model": "deepseek-flash", "status": "completed", "output": [ {"type": "reasoning", "status": "completed", "content": [{"type": "reasoning_text", "text": "The user wants to schedule a meeting. I should call schedule_event with the complete request."}], "encrypted_content": "713dda7a-2723-4f52-b22a-26483d8ee7e9-0"}, {"type": "function_call", "status": "completed", "name": "schedule_event", "arguments": "{\"request\": \"帮我约明天下午 3 点和张三开产品评审会,时长 1 小时\"}", "call_id": "call_00_Fsa6XTMl6dbQxpR4TFIb4432"} ], "usage": {"input_tokens": 347, "output_tokens": 75, "output_tokens_details": {"reasoning_tokens": 18}, "total_tokens": 422}}关键:这一次响应里没有任何面向用户的文本(output 只有 thinking + function_call)。所以此刻主管还没有能力回答用户——它连”会到底开成没有”都不知道。这就是必须还有第 4 次调用的根本原因。
② 子智能体的上下文:只有两句话(上下文隔离的实证)
"input": [ {"type": "message", "role": "system", "content": "你是日历助手。收到日程请求后,必须调用 create_calendar_event 创建事件,然后用一句话确认结果(包含标题与时间)。"}, {"type": "message", "role": "user", "content": "帮我约明天下午 3 点和张三开产品评审会,时长 1 小时"}]子智能体看不到主管的 system prompt,也看不到用户与主管的完整历史;它只拿到主管”转交的那一句话”(而且主管把原话改写成了带”时长 1 小时”的版本)。反过来,主管也看不到子智能体内部的推理与工具调用——只有 ③ 的最后一条文本会回去。
③ 子智能体的第二次调用:请求(工具结果回灌)
"input": [ {"type": "message", "role": "system", "content": "你是日历助手。…"}, {"type": "message", "role": "user", "content": "帮我约明天下午 3 点和张三开产品评审会,时长 1 小时"}, {"type": "function_call", "name": "create_calendar_event", "arguments": "{\"title\": \"产品评审会\", \"start_iso\": \"2025-06-11T15:00:00\", \"duration_minutes\": 60}", "call_id": "call_00_…"}, {"type": "function_call_output", "call_id": "call_00_…", "output": "OK: created '产品评审会' at 2025-06-11T15:00:00 for 60 min"}]响应(③):output_text = "已为你创建「产品评审会」,时间:明天(2025-06-11)15:00,时长 1 小时。" → 这句就是 schedule_event 工具的返回值。
④ 主管第二次调用:请求(工具结果回到主管手里)
"input": [ {"type": "message", "role": "system", "content": "你是个人助理主管。…"}, {"type": "message", "role": "user", "content": "帮我约明天下午 3 点和张三开产品评审会,1 小时"}, {"type": "reasoning", "content": [{"type": "reasoning_text", "text": "The user wants to schedule a meeting. I should call schedule_event…"}], "encrypted_content": "713dda7a-…-0"}, {"type": "function_call", "name": "schedule_event", "arguments": "{\"request\": \"帮我约明天下午 3 点和张三开产品评审会,时长 1 小时\"}", "call_id": "call_00_Fsa6XTMl6dbQxpR4TFIb4432"}, {"type": "function_call_output", "call_id": "call_00_Fsa6XTMl6dbQxpR4TFIb4432", "output": "已为你创建「产品评审会」,时间:明天(2025-06-11)15:00,时长 1 小时。"}]这里的 function_call_output 就是第 4 次调用的理由:主管的消息列表现在以”工具结果”结尾,而不是以”助手对用户说的话”结尾。模型必须再跑一次,才能把这条工具结果变成一条面向用户的 assistant 消息。
④ 主管第二次调用:响应(最终回答)
{ "model": "deepseek-flash", "status": "completed", "output": [ {"type": "message", "role": "assistant", "status": "completed", "phase": "final_answer", "content": [{"type": "output_text", "text": "已经帮你安排好了:明天(2025-06-11)下午 3 点和张三的产品评审会,时长 1 小时。"}]} ], "usage": {"input_tokens": 462, "output_tokens": 31, "input_tokens_details": {"cached_tokens": 256}, "total_tokens": 493}}五、第 4 次调用到底买到了什么
把 ① 和 ④ 对比着看最清楚——同一个主管、同一个 system prompt,唯一的差别是上下文末尾多了工具结果:
| ① 派活前 | ④ 收结果后 | |
|---|---|---|
| 上下文末尾 | 用户原话 | 用户原话 + function_call + function_call_output |
| 产出 | function_call(没有文本) | 面向用户的文本 |
| 它知道”办成了吗” | ❌ 不知道 | ✅ 知道(“已为你创建…”) |
| 能不能对用户说话 | ❌ 不能 | ✅ 能 |
所以第 4 次买到的是四样东西(前三样是本例直接体现的,第四样是多智能体场景的关键):
- 把内部结果翻译成”人话”:子智能体返回的是给程序看的结果文本,主管负责组织成面向用户的一句话;
- 闭环:让消息列表以
assistant消息结尾(前端”可以输入了”、对话历史合法); - 决定要不要继续:主管看到结果后可以选择再派一个子智能体、重试、或追问用户——这个”下一步判断”必须有模型参与;
- 合并多个子智能体的结果:并行派了 3 个子智能体时,只有主管能把 3 份结果合成一个回答。
六、次数公式(和官方文档的数字对得上)
一次用户请求的模型调用次数 ≈ 1(主管派活) + Σ 每个被调用的子智能体(1 次"决定调不调工具" + 1 次"拿结果作答",或最少 1 次) + 1(主管收尾)| 场景 | 公式 | 次数 | 与官方文档对照 |
|---|---|---|---|
| 本例:1 个子智能体 + 1 次工具调用 | 1 + 2 + 1 | 4 | 官方「一次性请求」Subagents = 4 calls ✅ |
| 3 个子智能体,各自直接作答(无工具) | 1 + 3 + 1 | 5 | 官方「多领域」Subagents = 5 calls / ~9K tokens ✅ |
| 子智能体每多一次工具调用 | 每多 1 次工具调用 +1 次模型调用 | 4 → 6 … | 官方没写,但机制决定 |
| 对照:Handoffs 一次性请求 | 交接 1 + 调工具 1 + 作答 1 | 3 | 官方「一次性请求」Handoffs = 3 calls ✅ |
结论:Subagents 的第 4 次(推广到 N 个子智能体就是最后那 1 次)是这个模式的”结构成本”:只要”所有结果都必须回到主管、由主管对用户说话”,就必须多一次汇总调用。它换来的是集中管控、结果合并和一致口径——当这些正好是你需要的,这 1 次就值;当任务简单、单一领域时,它就是纯浪费,该换成 Handoffs / Skills / Router(都是 3 次)。
七、自己复现(三步)
# 0) 准备:一个 Python 3.11 环境(需 langchain 1.x + langchain-openai + httpx)+ 一个隔离环境装 mitmproxypython -m venv /tmp/mitmenv && /tmp/mitmenv/bin/pip install mitmproxy
# 1) 起代理(CA 生成在 /tmp/subagents-demo/.mitmproxy/)/tmp/mitmenv/bin/mitmdump -q -s demo/capture.py -p 8888 --set confdir=/tmp/subagents-demo/.mitmproxy
# 2) 让 demo 走代理跑(模型配置用环境变量,勿写进代码)export CLOUD_MODEL_BASE_URL=https://api.deepseek.comexport CLOUD_MODEL_API_KEY=<你的 key>export CLOUD_MODEL_NAME=deepseek-v4-flash-vision-expexport CLOUD_MODEL_PROTOCOL=RESPONSESHTTPS_PROXY=http://127.0.0.1:8888 SSL_CERT_FILE=/tmp/subagents-demo/.mitmproxy/mitmproxy-ca-cert.pem \ python demo/demo.py
# 3) 把抓包汇总成人能读的清单python demo/summarize.py demo/captured.jsonl说明:
SSL_CERT_FILE指向 mitmproxy 的 CA 证书,httpx/OpenAI SDK 才会信任代理的中间人证书;不加这个会报CERTIFICATE_VERIFY_FAILED。 本次抓包文件demo/captured.jsonl只保存请求/响应正文,不含任何请求头与密钥。