Harmony built-in tools、tool session 与二段生成回路#

这章解决什么问题#

前面的结构化生成章节已经讲了普通 function calling、tool parser、Responses API 的 background 语义,以及 MCP tool server,但还有一层很容易让读者误以为“只是内建 demo 工具”的机制没有单独讲:当 Responses API 进入 harmony 路径,并启用内建 browser / python 工具时,系统不再是“一次生成完就结束”,而是会进入一条生成、调用工具、回填工具结果、再继续生成的二段甚至多段回路。

这章的目标,就是把这条 built-in tool 回路讲透。

为什么这层值得单独讲#

如果只把内建工具写成“支持浏览器和 Python”,整本书会漏掉一层很重要的工程事实:这些工具不是在 HTTP handler 外面随手包一层,而是通过 ConversationContext、tool session 和 harmony message 结构真正进入生成回路。

换句话说,它们不是“外插插件”,而是 output loop 的一部分。

一张图:built-in tool 回路为什么不是单段生成#

这张图解决的理解障碍是:很多读者会把 tool 调用想成“模型输出一次 tool call,服务端同步执行后收工”,但 harmony 路径实际上是一个带上下文回填的循环。

flowchart LR
    Prompt["engine prompt"] --> Gen1["generate_request(...)"]
    Gen1 --> Ctx["HarmonyContext.append_output()"]
    Ctx --> Need["need_builtin_tool_call()?"]
    Need -->|yes| Tool["call_tool() / tool session"]
    Tool --> Msg["tool result message"]
    Msg --> Render["render_for_completion()"]
    Render --> Gen2["second generation pass"]
    Need -->|no| Final["final response"]

图比纯文字多解释的一点是:tool 调用不是结果后处理,而是下一轮生成的输入准备阶段。

ConversationContext 才是这条回路的真正骨架#

python/sglang/srt/entrypoints/context.py 定义了一个很清楚的抽象:

  • append_output(...)
  • need_builtin_tool_call()
  • call_tool()
  • render_for_completion()

这四个动作刚好对应了 built-in tool 回路的四个阶段:

  1. 吃下模型刚刚生成的输出
  2. 判断是否需要进入 tool 分支
  3. 真正调用工具
  4. 把新消息重新渲染成下一轮 completion 的输入

从图书写法看,这是一个特别适合单独成章的“关键抽象”。因为它把一个看起来分散在 handler、tool server 和 message parser 里的流程压成了稳定接口。

HarmonyContextSimpleContext 的边界非常重要#

SimpleContext 基本不支持 built-in tool 回路;HarmonyContext 才真正实现了:

  • message 累积
  • tool session 保存
  • parser 驱动的 output append
  • built-in tool call 判断

这意味着 built-in tool 回路不是所有 API surface 的通用行为,而是 harmony 路径下的一种特定运行人格。把这层边界讲清楚,能避免读者把 Responses API 的全部行为都误当成普通 chat/completions 的自然延伸。

need_builtin_tool_call() 实际在检查什么#

HarmonyContext 里,判断条件并不神秘:看最后一条 message 的 recipient 是否以 browser.python 开头。

这说明 built-in tool 回路的入口不是另一个隐藏状态机,而是建立在 message 语义之上:

  • 模型/assistant 当前把消息发给谁
  • 这个 recipient 是否属于内建工具命名空间

从工程上说,这是一种把“工具分支”编码进消息协议的方式,而不是在 parser 外面再维护一套 parallel state machine。

tool session 为什么要在 AsyncExitStack 里统一管理#

serving_responses.py::create_responses() 会在需要时:

  • browser / python 建立 tool_session_ctxs
  • 通过 AsyncExitStack 统一进入和退出 session

这说明 tool session 不是一次性函数调用,而是有资源生命周期的上下文对象。对 MCP server 尤其如此,因为 session 里可能包含连接、能力协商和工具清单。

这也是为什么 built-in tool 工作流不能简单被理解成“拿到 tool call 就本地调个函数”。

harmony 系统消息为什么会注入工具描述#

_construct_input_messages_with_harmony(...) 里,系统会根据 request tools 和 tool server 能力决定是否把:

  • browser_description
  • python_description

注入 get_system_message(...)。这说明内建工具不是静默存在的;系统会把它们的描述显式并入 prompt / harmony message 语义。

这件事的工程意义非常直接:

  • 模型需要知道当前有哪些内建工具
  • tool 能力描述会影响它是否发出 browser.*python recipient

_generate_with_builtin_tools(...) 为什么让 Responses 变成多段生成#

create_responses() 不直接调用普通 generator,而是走 _generate_with_builtin_tools(...)。这说明 built-in tool 路径从一开始就不把“单轮生成”当作默认假设。

更稳的理解是:

  • 第一轮生成负责把 assistant 消息和 tool 意图产出来
  • context 决定是否需要工具调用
  • 如果需要,先执行工具,再把工具结果消息重新送回上下文
  • 再继续下一轮生成,直到不再需要内建工具或达到结束条件

这本质上是一个 runtime orchestrated loop。

browserpython 为什么被当成特殊命名空间#

HarmonyContext.call_tool() 里有明确分支:

  • recipient.startswith("browser.")
  • recipient.startswith("python")

前者会进一步拆出动作名,例如 browser.searchbrowser.openbrowser.find;后者则把最后一条消息内容当成代码交给 Python tool。

这说明两个 built-in tool 家族虽然都叫“工具”,但协议形状并不一样:

  • browser 更像多 action namespace
  • python 更像单 action、代码体即参数的执行器

这种差异值得技术书主动讲出来,因为它解释了为什么不同 built-in tool 会对消息格式提出不同要求。

为什么 background / streaming 与 MCP tool server 有额外限制#

serving_responses.py 明确拒绝某些组合:

  • MCPToolServer
  • 同时 backgroundstream
  • 且使用 web_search_preview / code_interpreter 之类工具

这是一个非常值得写进书里的行为边界。它表明工具工作流并不是所有表面组合都完全兼容。原因从代码上看不是“协议上不喜欢”,而是这类组合会让 session 生命周期、流式输出和后台存储语义同时变复杂。

这里仍然区分 事实推断

  • 事实:源码明确返回错误,禁止这些组合
  • 推断:上游这么限制,是为了避免 tool session 与 background/streaming 生命周期交织失控

这一层最容易踩的坑#

1. 以为 built-in tool 只是 tool server 的一个 UI 别名#

它实际进入了 ConversationContext 和多轮生成回路。

2. 以为工具结果只是普通日志或 side effect#

工具结果会被重新包装成 message,再进入下一轮 completion。

3. 以为 Responses API 一旦支持工具,就所有模式都兼容#

源码对 MCP + background/streaming 组合有明确限制。

如果 built-in tool 回路不工作,先怎么查#

建议按这个顺序:

  1. 当前请求是不是 harmony 路径,而不是 SimpleContext
  2. system/developer message 里是否真的注入了工具描述
  3. 最后一条 message 的 recipient 是否正确指向 browser.*python
  4. tool_session 是否成功建立
  5. 工具结果是否被重新 append 成 message,并被 render_for_completion() 送回下一轮

如果模型根本不发 tool recipient,问题更可能在 prompt/description 层;如果发了但工具没执行,问题更可能在 context/session 层;如果工具执行了但后续生成没接上,问题更可能在 append/render 的上下文桥。

小结#

这一章真正要补齐的,是结构化生成里最像“工作流编排器”的一条隐藏主线:

  • ConversationContext 让 built-in tool 回路有了正式骨架
  • harmony message 的 recipient 决定是否进入工具分支
  • tool result 会被重新编入上下文,再触发下一轮生成
  • 因而 built-in tool 不是输出后处理,而是生成循环的一部分

把这层读清楚之后,你再看 Responses、tool server 和 parser,就不会再把它们误读成几组松散拼接的功能点。