【Bug已解决】tool_choice=‘required‘+PD disaggregation; internal server error for GLM-5 解决方案
【Bug已解决】tool_choice='required'+PD disaggregation; internal server error for GLM-5 解决方案
一、现象长什么样
用 GLM-5 做工具调用(function calling),当请求里同时设置tool_choice='required'(强制模型必须调用工具)并且开启 PD 分离部署(Prefill 与 Decode 拆成两个独立实例:prefill 实例负责把 prompt 算到第一个 token,decode 实例负责后续自回归生成)时,服务端返回 500 Internal Server Error。典型日志:
Internal Server Error: failed to forward tool_choice=required request across PD boundary AssertionError: decode instance received no tool_call token from prefill或者更笼统:
tool_choice='required'+PD disaggregation; internal server error for GLM-5几个特征,帮你判断是不是同一个坑:
- 报错是HTTP 500(内部服务器错误),而不是正常的工具调用响应。
- 只要关掉 PD 分离(prefill 和 decode 在同一实例),同样的
tool_choice='required'请求就正常——说明问题出在「PD 分离 + 强制工具调用」的组合。 - 普通对话(不带
tool_choice或tool_choice='auto')在 PD 分离下也正常,只有'required'这种「强制首 token 必须是工具调用」的模式崩。 - 错误里常出现
tool_call、prefill、decode、forward、boundary这些关键字。
二、背景
要理解这个误报,得先理清两件事:tool_choice='required'到底要求什么,以及PD 分离时两个实例之间传了什么。
tool_choice='required'的语义:它要求模型生成的第一个 token 必须是一个「工具调用开始」标记(GLM-5 里通常是类似<|tool_call|>或对应特殊 token)。换句话说,prefill 阶段必须把 prompt 算到「下一个必须输出工具调用 token」的状态,并且这个约束要延续到 decode 阶段,让 decode 实例从「工具调用 token」继续自回归。
PD 分离的工作方式:prefill 实例处理完整 prompt,算出 KV 缓存和「最后一个位置之后的下一个 token 的隐状态/或第一个生成 token」,然后通过一个「传输协议」把 KV 缓存(以及必要的元信息)发给 decode 实例。decode 实例拿到 KV 缓存后,从那里继续生成。
冲突点:tool_choice='required'这种约束,有时不是单纯靠 KV 缓存就能传递的——它可能涉及:
- 首 token 的强制偏置(bias / logit mask):prefill 端为了让第一个 token 必为工具调用,会在采样时加一个 logits 偏置或强制 mask。如果这个偏置只设在 prefill 实例、没随请求传给 decode 实例,decode 实例就「不知道要强制工具调用」,可能生成普通文本而非
<|tool_call|>。 - KV 缓存外的元信息缺失:PD 传输协议默认只传 KV 缓存 + 位置信息,但
required约束所需的「采样参数 / 强制标记」如果没被序列化进传输的 request metadata,decode 端就丢失了这个约束。 - 特殊 token 在 decode 端的 vocab 对齐:GLM-5 的工具调用特殊 token,若 prefill 与 decode 实例的 tokenizer / 模型配置不一致(比如两个实例加载了不同版本),decode 端收到「工具调用 token id」却不认识,触发断言失败。
- 请求 schema 校验不全:网关/路由层在收到
tool_choice='required'+ PD 的复合请求时,没有校验「这种组合是否支持」,直接放行,到了 decode 端才发现约束无法满足 → 500。
核心矛盾:required是一个「跨实例的生成约束」,但 PD 分离默认只传递 KV 缓存,不传递这个约束的元信息,于是 decode 端丢失约束、生成出错、最终 500。
三、根因
根因一句话:在 PD 分离部署下,tool_choice='required'所需的「强制首 token 为工具调用」约束(采样偏置 / 特殊 token 标记 / 采样参数)没有被序列化进 prefill→decode 的传输元信息里,decode 实例丢失该约束后,要么生成了非工具调用 token 触发断言,要么因不认识工具调用 token 而抛 500。
具体成因:
- 约束未随 KV 传输:PD 传输协议只搬 KV 缓存,漏传
tool_choice='required'对应的采样约束/偏置,decode 端无约束可遵。 - 首 token 偏置仅设在 prefill 端:prefill 端为了
required在 logits 上加了强偏置,但 decode 端从第二个 token 起用自己的采样配置,偏置没延续。 - 特殊 token id 不对齐:prefill 与 decode 实例 tokenizer 版本不同,GLM-5 的
<|tool_call|>token id 在两端的 id 不一致,decode 收到 id 却映射到错误 token。 required与 PD 组合未校验:路由层没禁止/未适配「required + PD」,请求被直接转发到不支持该组合的 decode 实例。- 错误未优雅降级:decode 端发现约束缺失时直接抛异常 → 网关转成 500,而非返回一个清晰的「该组合暂不支持」或自动回退到非 PD。
核心矛盾:强制工具调用是一个需要两端协同的约束,PD 传输链却把它当成「只在 prefill 端有效的一次性设置」,导致约束在实例边界处断裂。
四、最小可运行复现
下面用纯 Python 模拟「prefill 设置 required 约束,但 PD 传输只搬 KV、漏传约束,decode 丢失约束」的情形:
# reproduce_pd_tool.py # 复现:required 约束未随 PD 传输,decode 端丢失 -> 500 class Request: def __init__(self): self.kv_cache = "kv_data" self.tool_choice = None # 随请求传递的约束 self.sampling_bias = None # prefill 端的强制偏置 def prefill(req: Request): if req.tool_choice == "required": req.sampling_bias = "force_tool_call_token" # 只设在 prefill 端 return req def transfer(req: Request): # PD 传输: 只搬 kv_cache,漏传 tool_choice / sampling_bias decoded = Request() decoded.kv_cache = req.kv_cache # decoded.tool_choice / decoded.sampling_bias 都为 None -> 漏传! return decoded def decode(req: Request): if req.tool_choice == "required" and req.sampling_bias is None: raise RuntimeError("decode 端丢失 required 约束,无法强制工具调用 -> 500") return "ok" if __name__ == "__main__": r = Request() r.tool_choice = "required" prefill(r) d = transfer(r) try: decode(d) except RuntimeError as e: print("复现成功:", e)运行python reproduce_pd_tool.py,会看到因「约束漏传」导致的失败,正是 500 的来源。
五、解决方案(第一层:最小直接修复)
最小修复:在 PD 传输协议里,把tool_choice='required'对应的约束(强制标记 + 采样偏置)一并序列化传输,decode 端据此重建约束。如果暂时改不了传输协议,先给路由层加一道「required + PD 不支持则回退」的开关。
# fix_layer1_pd_tool.py def transfer_full(req: dict) -> dict: """PD 传输时,除 kv_cache 外,强制带上 tool_choice 与采样约束。""" return { "kv_cache": req["kv_cache"], "tool_choice": req.get("tool_choice"), "sampling_bias": req.get("sampling_bias"), "forced_first_token": req.get("forced_first_token"), # GLM-5 工具调用首 token } def decode_with_constraint(req: dict): if req.get("tool_choice") == "required": if not req.get("sampling_bias") and req.get("forced_first_token") is None: # 兜底: 即使漏传也强制首 token 为工具调用标记 req["forced_first_token"] = "<|tool_call|>" # 用 forced_first_token 约束 decode 首 token return "ok" if __name__ == "__main__": r = {"kv_cache": "kv", "tool_choice": "required", "sampling_bias": "force"} d = transfer_full(r) print(decode_with_constraint(d)) # ok这一层:要么把约束随 KV 一起传(治本),要么 decode 端用默认工具调用标记兜底(治标),让required不再丢约束。
六、解决方案(第二层:结构性改进)
把「PD 请求约束的序列化 + 两端对齐」做成独立模块,明确列出「哪些约束必须跨实例传递」,并做 tokenizer 对齐校验:
# fix_layer2_pd_contract.py from dataclasses import dataclass, field # 必须在 PD 两端一致的请求约束 PD_CONSTRAINT_FIELDS = ("tool_choice", "sampling_bias", "forced_first_token", "tool_ids") @dataclass class PDRequestContract: kv_cache: object tool_choice: str | None = None sampling_bias: str | None = None forced_first_token: str | None = None tool_ids: list = field(default_factory=list) def to_wire(self) -> dict: """序列化: 含 KV 与所有约束字段。""" return { "kv_cache": self.kv_cache, **{f: getattr(self, f) for f in PD_CONSTRAINT_FIELDS}, } @classmethod def from_wire(cls, wire: dict) -> "PDRequestContract": missing = [f for f in PD_CONSTRAINT_FIELDS if f not in wire] if missing and wire.get("tool_choice") == "required": raise ValueError( f"PD 传输缺失约束字段 {missing},required 工具调用无法跨实例传递" ) return cls(kv_cache=wire["kv_cache"], **{f: wire.get(f) for f in PD_CONSTRAINT_FIELDS}) def validate_tokenizer_aligned(prefill_tok, decode_tok, forced_token: str): """确保工具调用特殊 token 在两实例 id 一致。""" pid = prefill_tok(forced_token) did = decode_tok(forced_token) assert pid == did, f"工具调用 token id 不对齐: prefill={pid} decode={did}" if __name__ == "__main__": c = PDRequestContract(kv_cache="kv", tool_choice="required", forced_first_token="<|tool_call|>") wire = c.to_wire() restored = PDRequestContract.from_wire(wire) print("约束跨实例保留:", restored.forced_first_token)这样:任何「required + PD」请求,约束字段都会随 KV 一起序列化,缺字段在 decode 端立即报错,且 tokenizer 对齐被显式校验。
七、解决方案(第三层:断言 / CI 守护)
把「PD 约束传输完整性 + tokenizer 对齐」钉进断言和 CI:
# fix_layer3_guard.py # ---- pytest 用例,进 CI ---- def test_required_constraint_travels_pd(): from fix_layer2_pd_contract import PDRequestContract c = PDRequestContract(kv_cache="kv", tool_choice="required", forced_first_token="<|tool_call|>") wire = c.to_wire() r = PDRequestContract.from_wire(wire) assert r.tool_choice == "required" assert r.forced_first_token == "<|tool_call|>" def test_missing_constraint_rejected(): from fix_layer2_pd_contract import PDRequestContract try: PDRequestContract.from_wire({"kv_cache": "kv", "tool_choice": "required"}) assert False, "缺约束字段应被拒" except ValueError: pass def test_tokenizer_must_align(): from fix_layer2_pd_contract import validate_tokenizer_aligned try: validate_tokenizer_aligned(lambda t: 100, lambda t: 200, "<|tool_call|>") assert False except AssertionError: pass再加路由层守卫,避免把「required + PD」发到不支持的实例:
def route_request(req: dict, pd_enabled: bool): if req.get("tool_choice") == "required" and pd_enabled: # 确保约束能跨实例; 否则回退到非 PD 单实例 if not req.get("forced_first_token"): return "single_instance" # 回退 return "pd"八、排查清单
GLM-5 的tool_choice='required'+ PD 分离报 500,按序查:
- 先关 PD 试:同一请求关掉 PD 分离能正常,说明问题在 PD 跨实例约束传递。
- 查是否只有
required崩:auto/none正常而required崩,说明是「强制首 token」约束没传过去。 - 看 PD 传输协议:确认 KV 传输时是否漏传
tool_choice/ 采样偏置 / 强制首 token 标记。 - 校验 tokenizer 对齐:prefill 与 decode 两实例的 GLM-5 tokenizer 版本是否一致,
<|tool_call|>的 id 是否相同。 - 加约束序列化:把
required相关约束字段写进 PD 传输的 request metadata,decode 端据此重建。 - decode 端兜底:即使漏传,也用默认工具调用标记强制首 token,避免直接 500。
- 路由层适配:网关识别「required + PD」组合,要么保证约束可传,要么回退单实例。
- 优雅降级而非 500:decode 发现约束缺失时返回清晰错误/回退,而非抛异常让网关转 500。
- 看 vLLM 版本:PD 分离 + 工具调用的协同在新版本才完善,升级常直接解决。
- 最后才动采样核:优先在传输协议/路由层修约束传递,不要为了对齐去改采样内核。
九、小结
GLM-5 在tool_choice='required'+ PD 分离下报 500,根子是「强制首 token 为工具调用」是一个跨实例生成约束,但 PD 传输链默认只搬 KV 缓存、漏传该约束的元信息,decode 端丢失约束后或生成非工具调用 token 触发断言、或不认识工具调用 token 而抛 500。修复三层:第一层把约束字段随 KV 一起序列化传输,decode 端缺失时兜底强制首 token;第二层抽PDRequestContract明确「哪些约束必须跨实例传递」并校验 tokenizer 对齐;第三层用 pytest 把「约束跨实例保留」「缺失即拒」「tokenizer 对齐」钉进 CI,路由层对不支持的组合回退单实例。核心认识——PD 分离不是「只传 KV」那么简单;任何影响生成结果的请求约束(采样偏置、强制标记、tool_choice)都必须被显式序列化进传输协议,并在两端校验一致,否则约束会在实例边界悄悄断裂。