glm47-streaming-text-markers¶
Source: fixes/glm47-streaming-text-markers/
Make the glm47 tool parser recognise tool-call markers written as plain text when streaming, so
streaming and non-streaming responses agree.
| Affects | GLM-4.7 and GLM-5 family models served with --tool-call-parser glm47, stream: true, forced tool calls |
| Releases | v0.26.0, v0.30.0 (one script, two source shapes) |
| Patches | vllm/parser/glm47_moe.py (checks vllm/parser/engine/*.py) |
| Upstream | not filed yet (the engine behaviour is unchanged on vLLM main) |
| Test | none |
Problem¶
The GLM tokenizer has added tokens for <tool_call> and </tool_call>, but the model can also
write them as ordinary BPE pieces, and the byte-level structural tag used for forced tool calls
accepts both spellings. vLLM's streaming parser engine treats a terminal that also has a token-id
form as content once the stream has carried token ids, so in streaming the markers are recognised
only by id. The non-streaming path parses text, so it sees the same text as markers.
Symptom¶
With stream: true and tool_choice: "required" or a named tool, some responses carry the whole
call as raw text in delta.content
(<tool_call>name\n<arg_key>...</arg_key>\n<arg_value>...</arg_value>\n</tool_call>), with no
tool_calls and finish_reason: "stop". The same request with stream: false returns a proper
tool call. A second, rarer symptom: a call the model closes with a malformed value streams
arguments that are not valid JSON.
Who is affected¶
Only deployments using the glm47 parser. Other parsers are not touched, so this fix is not part
of the default profile; recipes for GLM models list it.
The fix¶
For the glm47 parser only, per parser instance and only in streaming:
- a plain-text
<tool_call>opens a call after reasoning has ended when the request carries a structural tag, which means the grammar forces a call there; free-form chat and text inside reasoning are unchanged; - a plain-text
</tool_call>closes an open call; <think>and</think>stay id-only, since their plain spelling appears in prose;- if the model closes a call while its last value is unterminated, the streamed arguments are
closed as valid JSON. A call cut off by
max_tokensis streamed exactly as before.
The override reproduces the engine's token loop, so the script checks the exact engine code of each release line and refuses to patch anything else.
Verify¶
python3 fixes/glm47-streaming-text-markers/apply.py --check # 0 = applied, 1 = not applied, 2 = source drifted
End to end: send the same forced-tool-call request many times with stream: true and with
stream: false; every streamed response should carry tool_calls like the non-streamed one.