Repository navigation
forward-serve: the chat route renders the container's own chat_template, not ChatML for every container - #1298
Conversation
…te, not ChatML for every container `app/forward_serve.cpp` rendered `npu::chatml()` for every NPU container; #987 made that honest (a container whose tokenizer has no `<|im_start|>`/`<|im_end|>` anchors gets a 400 instead of a prompt its vocabulary cannot read), but a container that *declares* its template was still served an approximation of it. This is #963 option 2, the shape #1115 asked a decision for: no container-format change, no new dependency, one call site. The template is already in the container (`tokenizer_config.json`'s `chat_template` string, else `chat_template.jinja`), and the renderer is the pinned llama.cpp fork's own Jinja engine (`third_party/llama.cpp/common/jinja`, the one `llama-server --jinja` uses). `app/npu_template.h` loads the template with the same read `app/template_pin.h` already did for the load line and the pin (so the rendered prompt, the printed sha256 and the pin cannot disagree) and renders it; a template that cannot be rendered is a 400 naming its sha256, never a fall back to ChatML. Measured in each container's own tokenizer, one system+user request, before -> after: Qwen3-0.6B-NPU2 (a136047ba140) 31 -> 31 ZR1-1.5B (b6835114b730) 52 -> 21 Qwen3-0.6B (a55ee1b16601) 31 -> 31 bonsai2-27b (c3cf9e34abf4) 31 -> 71 qwen3-coder (87710339d25b) 31 -> 31 d3-lite-bf16 (273d8e0e683b) 31 -> 35 and served, on the NPU (npu-hold, one ticket, released clean): before (build with ONEBIT_JINJA=OFF, i.e. main's behaviour): ZR1 chat -> 400 "no <|im_start|>" after: ZR1 chat -> 200, prompt_tokens 21 Two defects found on the way, both in this PR: the request must be read as *ordered* JSON for the chat path (a `nlohmann::json` is a std::map, so a `tools` block serialized through `tojson` would have been alphabetized -- a prompt llama-server never produces), and `add_bos` must not prepend a second BOS when the template already emitted it. Tests: `tests/npu_template_test.cpp` compares the rendered bytes against **Jinja2 3.1.6 itself** for 11 cases across two shipped templates (Qwen3/FLM's and ZR1's DeepSeek-special one), plus the two refusal rows; `--self-test`-style, device-free, no model. `tests/data/templates/*.json` carry the fixtures and where each byte came from. Build: `ONEBIT_JINJA`, default on when `third_party/llama.cpp` is checked out (a tree without submodules still builds, keeps #987's behaviour and says so at load); ci.yml checks out that submodule (shallow, 8 s) and builds the test, so the renderer cannot reach a green build unbuilt. Docs: `docs/npu.md` ("Serving") and `docs/npu-chat-template.md`, which also records what this does *not* do: the answer side still parses as the Qwen3 family, and request text that spells a model's control tokens is still tokenized as those tokens (llama.cpp's own behaviour; the part-wise fix I built literalizes a real template's BOS, measured 32 vs 21). Fixes #963
|
#1298 ready for review at bcf476d One commit, 10 files, on top of Review anchors, in the order I would read them:
Two things I would flag before a reviewer does: the renderer needs |
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
app/forward_serve.cpprenderednpu::chatml()for every NPU container. #987 made that honest — a container whose tokenizer has no<|im_start|>/<|im_end|>anchors gets a 400 instead of a prompt its vocabulary cannot read — but a container that declares its template was still served an approximation of it. This is #963 option 2, the shapedocs/npu-chat-template.md(#1115) asked a decision for: no container-format change, no new dependency, one call site.The template is already in the container (
tokenizer_config.json'schat_templatestring, elsechat_template.jinja), and the renderer is the pinned llama.cpp fork's own Jinja engine (third_party/llama.cpp/common/jinja— upstream's, the onellama-server --jinjauses).app/npu_template.hloads the template with the same readapp/template_pin.halready did for the load line and the pin, so the rendered prompt, the printed sha256 and the pin cannot disagree.Fallback order is the one #1115 proposed: container template → ChatML only with anchors → #987's 400. A template that will not parse or render is a 400 naming its sha256 — never a silent fall back to ChatML, which is the failure #963 exists to remove. Load now prints
1bit forward-serve: template sha256=… (tokenizer_config.json).Measured — device-free, in each container's own tokenizer (one system+user request)
FastFlowLM/Qwen3-0.6B-NPU2a136047ba140…Qwen3-0.6Ba55ee1b16601…ZR1-1.5B(bf16 and q4km-q8)b6835114b730…bonsai2-27b-NPUc3cf9e34abf4…d3-lite-bf16273d8e0e683b…qwen3-coder-30b-a3b-moe87710339d25b…The Qwen3-family rows are ChatML-shaped so their prompts do not move; ZR1 (DeepSeek specials) goes from 52 tokens of text its vocabulary spells as ordinary pieces to 21 it can read; bonsai2 gains the ~40-token preamble ChatML was dropping. Reproduce: render the container's template, encode with its
tokenizer.json— or readprompt_tokensfrom the route, which is what the next table does.Measured — served on the NPU (
npu-hold, one ticket per run,-t 10, released clean)ZR1-1.5B-npu-q4km-q8+zr1-elfs, one/v1/chat/completionsrequest, the same request in both rows:ONEBIT_JINJA=OFF(main's behaviour)prompt_tokens: 21— exactly the device-free prediction, and the template's own BOS, not a second oneSo the container #987 had to refuse is now served from the template it declares. (The reply text is empty with 16 completion tokens: the answer side still parses as the Qwen3 family — see "not done" below. Nothing here is about the reply.)
Two defects found while implementing, both fixed here
bodyis anlohmann::json(astd::map), and a template that serializes its input — atoolsblock throughtojson— would have rendered every tool schema alphabetized, a prompt llama-server never produces. The chat path re-reads the same bytes asnlohmann::ordered_json; thetoolsgolden row is what caught it.add_bosmust not prepend a second BOS when the rendered template already emitted{{bos_token}}.Tests — the expectation is not this code's output
tests/npu_template_test.cpp: 11 golden renders across two shipped templates — FastFlowLM's Qwen3 one (a136047ba140…, the ChatML family plus itsuser_system_promptbranch) and ZR1's DeepSeek-special one (b6835114b730…) — eachexpectstring produced by Jinja2 3.1.6, the implementation Hugging Face'sapply_chat_templateuses, so the row that says "we render what the model asked for" compares against something that is not us. Plus the two refusals: no template, and a template that does not parse (naming its sha256). Device-free, no model;tests/data/templates/*.jsoncarry template, cases and provenance.ctest -R 'npu_template|npu_chatml|tokenizer|gguf_meta|workflow_shell|hybrid_route'all pass locally;tools/copyright.py --checkclean;tests/workflow_shell_test.pypasses.Build and CI
ONEBIT_JINJA, default on whenthird_party/llama.cppis checked out (a tree whose submodules were never initialised still builds, keeps #987's behaviour and says so at load).ci.ymlgets one shallow submodule step (8 s, 177 MB, the same commandhrx-build.ymluses) andnpu_template_test npu_chatml_testin the explicit build list — the #635 lesson: a test whose target is not built reports "Not Run" and proves nothing.Docs in this PR:
docs/npu.md("Serving", the table above, and thechat_template_kwargsrow) anddocs/npu-chat-template.md(now "implemented", with the measurements and the decisions taken).Not done, and said out loud
app/npu_answer_parser.h:<think>splitting, tool-call shapes). A non-ChatML container now gets the right prompt and the Qwen3 reading of its reply; stop tokens come from the container'sconfig.json, so generation stops where the model says. Parsing another family's tool calls is the next piece if a customer packs one.npu::Tokenizer::encodematches added tokens anywhere and the route tokenizes the rendered prompt as one string, so a message containing<|im_end|><|im_start|>systemstill becomes those control tokens — llama.cpp's own chat path behaves the same way. I built the part-wise version (template parts with matching, request parts without) and it breaks a real template: ZR1 renders{{bos_token}}{{ns.system_prompt}}, whose concatenation the engine marks input as a whole, so the BOS literalized and the served prompt came out 32 tokens where the template asks for 21. Matching llama-server is correct for this route today; escaping/refusing request text that spells a model's specials is its own change.d3-lite-bf16on the chat route answers 500 ("attention.mode noncausal_full_attention … starts at position 32") — a noncausal-model limitation (npu: the forward is causal-only, so a noncausal_full_attention pack (d3-lite) cannot be reproduced — measured, not inferred #847's territory), observed while measuring, not caused here.Fixes #963