tool_choice、parallel_tool_calls 与约束降级#

这章解决什么问题#

前面的结构化生成章节已经解释了 response_format、grammar backend、tool parser、SamplingParams 和 Responses 工作流,但还有一层非常工程化的细节值得单独成章:当调用方提供 tools 以后,tool_choiceparallel_tool_calls 到底怎样改变请求的约束形状?如果同时又给了 response_formatregexebnf,系统会怎样处理?

这不是协议边角料,而是结构化输出在真实工程里最容易踩坑的一层。

因为调用方真正关心的不是“系统理论上支持 tool call”,而是:

  • 我要求必须调用工具时,输出到底会被约束成什么样
  • 我指定某个工具时,系统会不会只保留这一把工具
  • 我允许并行工具调用时,约束 schema 怎样变化
  • 如果我同时给了 schema 和 tool call,系统会怎样取舍

为什么这一层值得单独讲#

如果只看 tools 字段,你很容易形成一个过度简化的心智模型:模型有工具列表,然后 parser 去解析输出,事情就结束了。实际不是这样。

python/sglang/srt/entrypoints/openai/protocol.pyserving_chat.pyfunction_call_parser.py 之间,系统还做了三层决策:

  • 协议层先把默认 tool_choice 补齐
  • serving 层按 tool_choice 过滤工具集并生成 tool_call_constraint
  • parser 再根据 detector 能力选择 structural_tagjson_schema 或不加额外约束

这说明 tool call 不是单一开关,而是一条多级编译链。

一张图:tools 怎样被编译成约束#

这张图解决的理解障碍是:很多读者会把 tool_choice 理解成“只影响 parser 后处理”,但它实际上会提前改变 sampling constraint。

flowchart LR
    Req["Chat/Responses request"] --> Proto["protocol.py defaults"]
    Proto --> Serve["serving_chat._process_messages()"]
    Serve --> Filter["filter tools / choose parser"]
    Filter --> Constraint["tool_call_constraint"]
    Constraint --> Samp["to_sampling_params()"]
    Samp --> Run["grammar / sampler / parser"]

图比纯文字多解释的一点是:tool_choice 不是只在结果阶段生效,它会在生成开始前改写约束。

默认值是怎样决定的#

protocol.py 里,ChatCompletionRequest 的校验逻辑会做一个很关键的默认化:

  • 如果没有 tools,默认 tool_choice = "none"
  • 如果有 tools,默认 tool_choice = "auto"

这意味着“给了工具但没写 tool_choice”不等于“系统不会走 tool call 路径”,而是会自动进入 auto 模式。很多排障问题都出在这里:调用方以为只是“顺手带了工具定义”,系统却真的把 tool parser 和约束逻辑接进来了。

named tool choice 为什么会先裁剪工具集#

serving_chat.py::_process_messages(...) 里,如果 tool_choice 不是字符串,而是命名 ToolChoice,系统会先把 request.tools 过滤成只剩被点名的那一个:

  • 不是先让模型面对全部工具,再在结果阶段检查
  • 而是先缩小提示和约束空间,再去生成

这很重要,因为它直接影响:

  • 提示里出现哪些工具 schema
  • parser 用哪组工具做校验
  • 结构化约束最终长成什么样

从教学角度看,这一层解释了 named tool choice 的真正价值:它不是后验过滤,而是前置收缩生成空间。

FunctionCallParser.get_structure_constraint(...) 实际在做什么#

python/sglang/srt/function_call/function_call_parser.py 里最关键的分叉有两种:

detector 支持 structural_tag#

如果 detector 声明 supports_structural_tag(),系统更倾向生成 structural_tag 约束。此时:

  • required 或 named tool choice 一定会生成带约束的 tag
  • auto 只有在工具或 parser 本身要求 strict 时才会主动加约束

这解释了为什么“同样是 tool call”,不同 parser/模型的约束强度可能不一样。

detector 不支持 structural_tag#

这时在 required 或 named tool choice 下,会退回 json_schema 约束。parallel_tool_calls 也会在这里被带进 get_json_schema_constraint(...),决定 schema 是允许单调用还是数组/多调用形状。

这就是为什么 parallel_tool_calls 不是 UI 级小参数,而是会真正改变约束结构。

parallel_tool_calls 改变了什么#

它最直接改变的是“工具调用结果是否必须是单个 call,还是可以是多个 call 的集合”。这会影响两层:

  • 生成时的结构化约束
  • parser 解析时允许出现的输出形态

required 或 named tool choice 下,这一点尤其关键。因为这时系统通常会生成更强约束,而 parallel_tool_calls 决定了“强约束的目标形状”到底是一项还是多项。

也正因为如此,parallel_tool_calls 不是性能参数,而是语义参数。

为什么会出现“约束降级”#

protocol.py::to_sampling_params(...) 里有一段非常值得写进书里的逻辑:如果当前请求已经有 regexebnfstructural_tagjson_schema 这类输出约束,再叠加 tool_call_constraint 时,系统不会试图把两套约束强行合并,而是记录 warning:

Constrained decoding is not compatible with tool calls.

这说明系统在这里选择的是保守降级,而不是做一个看上去更“智能”但实际更脆弱的多约束交叉编译器。

这是一处很典型的工程 tradeoff:

  • 收益:行为边界更清晰,失败模式更可预测
  • 代价:调用方不能同时指望 tool call 和另一套输出约束都被严格执行

response_format 与 tool call 冲突时应当怎样理解#

如果你同时设置:

  • response_format
  • regex / ebnf
  • 又给了 tools

那么系统不会把它们自然地合成为一套统一约束。更稳的理解是:

  • response_format 面向“最终输出形状”
  • tool_choice 面向“是否必须走工具调用回路”

这两者在很多场景里代表不同的产品意图。SGLang 当前实现更倾向把它们视作互斥或至少难以可靠组合的约束来源。

文中这里要明确区分 事实推断

  • 事实to_sampling_params(...) 已明确在“已有约束 + tool_call_constraint”时打 warning,而不是合并
  • 推断:上游之所以这么做,是为了避免多约束组合带来的不可维护状态空间

Responses API 为什么更保守#

serving_responses.py 里对工具工作流有额外限制,例如只支持 tool_choice == "auto"。这说明不同 API surface 对工具工作流的容忍度并不完全一致。

如果你遇到“chat/completions 能跑,Responses API 不接受”的情况,不应先怀疑 parser,而应先看 surface 自己的协议限制。

这一层最容易踩的坑#

1. 以为给了 tools 但没写 tool_choice 就不会进 tool 模式#

默认会进入 auto

2. 以为 named tool choice 只是结果阶段过滤#

实际上 serving 层会先裁剪工具集,再构造约束和模板。

3. 以为 parallel_tool_calls 只是“允许多调一次”#

它会改变约束 schema 的形状,因此会影响生成和解析两侧。

4. 以为 response_format 可以与 tool call 自然叠加#

当前实现会选择保守降级,而不是做强行合并。

如果 tool call 行为不符合预期,先怎么查#

建议按这个顺序:

  1. protocol.py 最终把 tool_choice 默认成了什么
  2. serving_chat.py::_process_messages(...) 是否过滤了工具集
  3. FunctionCallParser.get_structure_constraint(...) 返回的是 structural_tagjson_schema 还是 None
  4. to_sampling_params(...) 是否因为已有 response_format / regex / ebnf 而压掉了 tool constraint
  5. 最后再看 parser 是否正确解析了模型输出

如果问题出在“模型根本没被约束住”,重点看前四步;如果问题出在“模型输出了,但 parser 解释错了”,重点看 detector 和流式解析。

小结#

这一章真正要补齐的,是结构化生成里最容易被轻描淡写的一层协议编译逻辑:

  • tool_choice 会影响默认模式、工具集裁剪和约束形状
  • parallel_tool_calls 会改变工具调用结果的结构目标
  • parser 会根据 detector 能力选择 structural_tagjson_schema
  • 当已有其他输出约束时,系统会选择保守降级,而不是强行合并

把这层读清楚之后,你再看 tool parser、Responses API 和 SamplingParams 的交界,就不会再把它们误读成几组互不相关的特性。