Repository navigation
server: GLM tool calls stream their arguments as they are written - #2050
Conversation
|
Thanks @devopsfortunato, this is careful work. Mirroring Two small things before we land it:
Leaving DSML out is the right call for now. Its containment test is a contract we want to keep, so that one will need a maintainer decision. #2049 only touches the same |
f13d002 to
fb8d9fc
Compare
|
Done in
Full no-GPU set re-run on this head (DGX Spark, Zig 0.17.0, empty local cache): On the landing order: agreed — #2049 and #2050 share only the |
|
Thanks @devopsfortunato. Both points are covered: the repeated-key test pins the stream and the whole reply for both the GLM and XML spellings, and the |
…hhart#1975) A GLM call (<tool_call>NAME<arg_key>K</arg_key><arg_value>V</arg_value>...) used to abort the streamer and go out in one piece at the finish. Its header now goes out once the name is whole and names an offered tool, then its string values as JSON-escaped pieces (holding back only a partial </arg_value>) and its typed values as compact JSON at their closer. tool_glm_stream.zig mirrors tool_parse.glmCall's reading, valueKey and repairGlmKeys included, over resumed indexes, so the joined pieces equal the whole reply's arguments byte for byte. DSML calls are unchanged. Two stream/whole differences found on the way, same bar: - after a call had streamed, an envelope the streamer could not stream set it to abort, so every later call was missing from the stream; it now parses that envelope at its closer instead, and aborts only while nothing streamed. - typed XML values streamed with Python's ", " separators while the whole reply's arguments are compact.
|
Thank you @devopsfortunato. This is merged into main with your commits under your own name, so you're listed as a TensorFold contributor. It ships in the next release, credited to you in the changelog and the release notes. |
What this changes
GLM tool calls stream their arguments while the model writes them, as Qwen-style XML calls already do. Today a GLM call (
<tool_call>NAME<arg_key>K</arg_key><arg_value>V</arg_value>…</tool_call>) aborts the streamer (.callsees text that does not start with<function=), so the whole call goes out in one piece at the finish: one delta with the header, one with all the arguments.Now, when no DSML opener has been seen:
<arg_key>after<tool_call>) and names an offered tool;</arg_value>;tool_glm_stream.zigmirrorstool_parse.glmCall's reading incrementally —valueKey's key pairing (a key runs to the first</arg_key>an<arg_value>follows) andrepairGlmKeys' lone-closer repair (a repaired key streams only when the counts seen so far sayrepairGlmKeyswould repair it) — over resumed indexes, so a long call costs linear time. DSML calls are unchanged: see the open question below.On the issue's probe (a 2,000-character
Writecontent fed 3 bytes at a time):maingivesheaders 0, argument deltas 0, state abort; this head givesheaders 1, argument deltas 677, the first argument bytes arrive while the value is still being written, and the joined arguments equal the whole reply's.Two stream/whole differences found on the way, same bar (#1396's), fixed here:
Calls lost after a call had streamed. Any envelope the streamer could not stream — an unoffered
<function=other>, a GLM or JSON payload,<|tool_call>,<NAME:tool_call>— set it to.abort, so every later call was missing from the stream while the whole reply still returned it (openai.zigdoes not replay calls whentool_calls_streamedis set). Example: XMLlookup(a), unofferedother(b), XMLlookup(c)— whole 2 calls, stream 1. Now such an envelope is parsed at its closer and its calls sent; the streamer aborts only while nothing has streamed, which keeps today's "finish sends every call" path.Typed values were not compact. XML typed values streamed as
["a", "b"]while the whole reply's arguments are["a","b"](json.stringifywithout.compact).Issue: Refs Streamed chat: GLM and DSML tool calls arrive in one piece, only XML calls stream their arguments #1975 (the GLM half; the DSML half is an open question below)
Stacked on:
maina6c5b97eb0b352eccdfa1703958bc068df0eeee8(one commit,f13d002d0). Independent of the Streamed chat: four more cases where the stream and the whole reply disagree (single-call envelopes, max_tokens cuts, DSML whitespace) #1396 one-call fix; the two only share the test-import line inzig/src/server/root.zig, a trivial rebase either way.atc overlap --json: 16 open pull requests share files; 10 merge clean, 6 reportunknown(pullFilesUnavailable). Bygit merge-tree, dsv41: serve structured output with the lane core's grammar masks #1665 merges clean against this branch; the other five (2009, 1980, 1871, 1295, 1336) touch unrelated regions or docs.Open question — DSML. The issue also asks for DSML calls to stream their arguments. I left that out deliberately: DSML blocks parse all or nothing (
tool_dsml.callsrejects a whole block on any malformed or unoffered invoke, andenvelopesmasks what is inside), and the existing containment contract instream_dsml_test.zig("unfinished and malformed DSML never emit their complete nested call") requires that an unclosed block stream no call at all. Streaming a DSML invoke before its block closes would break that contract and could send a call a later malformed invoke then rejects, which cannot be taken back. That trade-off needs a maintainer's decision, so this PR keeps DSML exactly as it is.zig/src/families/deepseek_v41/serve/tool_stream.zigis a related DSML streamer on the served-engine side.Checks on this head
fb8d9fcff(basea6c5b97eb0b)python3 tools/zig/lean_check.py, on the base and on this head: base 424 problems, head 424 problems,lean_new.py0 new..zig-cacheper run; these are the no-GPU CI steps from.github/workflows/no-gpu.yml):zig build test --test-timeout 10m→ 494/494 steps; 6572 passed, 48 skipped, 0 failedzig build test-kernel-contract→ 22/22 steps; 177/177zig build test-golden→ 5/5 steps, no golden file changedzig build test-server-security test-pull-security→ 14/14 steps; 28/28zig build fuzz-cortheon→ 3/3 steps; 19/19zig build test-cuda-host→ 293/293 steps; 2967/2975 (8 skipped)zig build(plain) → 31/31 stepszig build test-server-cpu→ 5/5 steps; 960/960python3 -m unittest discover -s tools/tests -p "test_bench*.py"→ 74 OK;tools/zigflashnext_batch_serve → 8 OK; build_options → 5 OK (2 skipped)zig fmt --check build.zig zig→ clean;git diff --check→ cleanunicodedataand this change doesn't touch the tables; GPU and model checks don't apply to a server-only parsing change. No/v1/messagesend-to-end test was added: Anthropic'sinput_json_deltais built from the same argument deltas (anthropic.zigdrain), so GLM calls now produce many of them instead of one.New tests:
zig/src/server/tool_glm_stream_test.zig(8 tests) — the 2,000-byte value streaming before it closes and joining to the whole reply's arguments; every feed boundary for three replies (typed values, non-ASCII, a repaired key, and the lone-closer case the first review round caught); ten GLM shapes; unstreamable envelopes sending nothing; a GLM call after a DSML block staying buffered as today; the end-tokencloseCallpath; cut replay equal totool_glm_cut; and a whole-vs-streamed chat table through the real chat path, batched and per character (prose plus GLM, two GLM calls, the unoffered-middle case, XML+GLM+XML, typed values, amax_tokenscut inside a value, zero-argument, key without a value). Every test that names a bug was red before the change; the unstreamable and DSML-first cases are regression assertions.A follow-up adds the two checks the maintainer asked for: a test pinning the repeated-key gap in both GLM and XML spellings, and a
/v1/messagesstreaming test that drives the real HTTP route with a scripted engine and asserts the Anthropicinput_json_deltapartial_jsonpieces (19 of them) join byte-for-byte to the whole reply'stool_useinput; with GLM streaming disabled in a throwaway copy that test fails with one piece, so it is live. Production code was not touched by the follow-up.Reviewed over two rounds on the production change; round 1 found one real bug (the lone-
</arg_key>repair was optimistic whererepairGlmKeysis all-or-nothing, so the stream could invent an argument — counter-example now a test), round 2 approved with no correctness findings.Receipt
n/a — the change is in the HTTP server's tool-call streaming (
tool_stream.zig, newtool_glm_stream.zig); it never reaches inference, so no exactness or speed receipt applies. Drafted output, resumed prompts and concurrency are unaffected for the same reason.Known limitations, named so they are decisions rather than surprises: a
</tool_call>written inside an<arg_value>is not special-cased (XML behaves the same way); an end token inside a GLM key leaves the stream's call open where the whole reply returns none (XML has the same case for<parameter=); a call with two key malformations, where later text changesrepairGlmKeys' whole-payload decision, can still differ (single-malformation cases are guarded and tested; noted in the module header); a repeated key streams both pairs while the whole reply's argument object keeps the last one — XML has the same gap today, and both formats are pinned by a test so it stays visible ({"q":"a","q":"b"}streamed,{"q":"b"}whole).Checklist