Download docs/structured-output-postmortem.md from Leon4gr45/minicpm: direct link, hf CLI and curl.
- Browser
- Download file 8.51 kB
-
https://huggingface.co/spaces/Leon4gr45/minicpm/resolve/main/docs/structured-output-postmortem.md
- Command line
-
hf download hf://spaces/Leon4gr45/minicpm/docs/structured-output-postmortem.md
-
curl -L -o structured-output-postmortem.md https://huggingface.co/spaces/Leon4gr45/minicpm/resolve/main/docs/structured-output-postmortem.md
Structured output (response_format) postmortem
Symptom
POST /v1/chat/completions with response_format: {"type": "json_object"} or
{"type": "json_schema", ...} returned:
HTTP 400
{"error":{"code":400,"message":"Failed to initialize samplers: std::exception","type":"invalid_request_error"}}
for every request that used response_format, regardless of schema
shape. Plain, unconstrained chat completions worked fine.
Deployment audit (Phase 1)
start.shruns/app/llama-serverdirectly, single model (--hf-repo openbmb/MiniCPM4-0.5B-GGUF:Q8_0 --alias minicpm4-0.5b), with--parallel 1 --cont-batching --flash-attn auto, no--jinja, no router flags, no grammar-related flags. Router/model-preset mode is not in use — this is llama.cpp's plain single-model server. This was verified both by readingstart.sh/Dockerfileand by reproducing the exact same failure against a from-source build run the same way, single model, no router.- The Docker image is
FROM ghcr.io/ggml-org/llama.cpp:server— a floating tag. The public Space'ssystem_fingerprinton a plain completion reportedb11118-e6ab7c1a4, i.e. build b11118 at commite6ab7c1a4.... gateway.pyforwardedresponse_format/grammar/tools/streamfields to llama.cpp unmodified, and turned upstream HTTP errors into anHTTPExceptionwith the same status code — it did not swallow or fake success, but it re-wrapped llama.cpp's JSON error body as an escaped string inside{"detail": "..."}", which is awkward for API clients (see "Gateway changes" below).
Root cause (Phase 3)
Reproduced directly against llama-server (bypassing the gateway), including
against a from-source build of current llama.cpp master (commit
84e76d8, ~12 days newer than the deployed build), so this is not a
stale/floating-tag regression — it is a live upstream bug.
Two distinct bugs, found by attaching gdb (catch throw) to a from-source
debug build and by adding a temporary diagnostic print of the caught
exception's typeid and what():
Bug 1 — the "std::exception" message is a symptom, not the disease
common/sampling.cpp, in common_sampler_init, prefills the grammar
sampler with the chat template's generation-prompt text before generation
starts:
try {
for (const auto & token : prefill_tokens) {
llama_sampler_accept(grmr, token);
}
} catch (std::exception &e) {
LOG_ERR(...);
throw e; // <-- BUG: copies/slices `e` down to plain std::exception
}
throw e; copies the caught exception object using the static type
of the catch clause (std::exception), discarding whatever real subclass
(here std::runtime_error) and message it actually was. That's why every
failure surfaced as the useless "std::exception" instead of the real
message. The fix is the standard one-line correction: bare throw;
(verified locally — see below).
Bug 2 — the real error: a tokenizer/grammar-literal mismatch
With bug 1 patched, the real message is:
Failed to initialize samplers: Unexpected empty grammar stack after accepting piece: assistant (16434)
llama.cpp's generic auto-parser (used because minicpm4 has no dedicated
tool-call parser, only minicpm5 does) builds response_format's grammar
wrapped in a literal chat-template prefix, e.g.:
root ::= "<|im_start|>assistant\n" space space ( "```json" space response-format space "```" | space response-format space )
and then "prefills" that literal into the grammar sampler by tokenizing the
prefix text and feeding the tokens through llama_sampler_accept
(common/sampling.cpp). For minicpm4-0.5b, the token for the word
"assistant" immediately after the <|im_start|> control token decodes to
" assistant" (with a spurious leading space) — the tokenizer has no
space-less "assistant" token, because in ordinary text that word is always
preceded by real whitespace. That leading space doesn't match the raw
literal text the grammar rule was built from ("assistant", no space), so
the grammar's internal stack empties out and
llama_grammar_accept_token (src/llama-grammar.cpp) throws.
This reproduces for every response_format request for this model —
json_object with no schema, and any json_schema, simple or complex —
because the broken step is the literal-prefix prefill, not anything
schema-shape-specific. (A separate, already-fixed upstream bug —
json-schema-to-grammar's "empty object schema" "{" space space "}"
GBNF bug, ggml-org/llama.cpp#25923, fixed in PR #28736 on 2026-09-12 — is
not the cause here: the deployed build (e6ab7c1a4, 2026-09-22)
already contains that fix.)
A plain "grammar": "<gbnf>" request (bypassing response_format and
the auto-parser's literal-prefix/prefill path entirely) works reliably —
confirmed directly against the exact deployed llama-server build. That is
the basis for the fix shipped here.
Fix (Phase 4/5)
Per the task's preferred order, option A (server/request configuration) turned out to be sufficient and lowest-risk — no image rebuild, no llama.cpp version change needed:
schema_grammar.pydeterministically compiles the JSON Schemas this service needs (object/boolean/string(+enum),required,additionalProperties: false, and untyped/empty-object placeholders) into GBNF, andgateway.pysends that asgrammarinstead of forwardingresponse_formatto llama.cpp. This avoids the buggy literal-prefix prefill path entirely.- The converter accepts the OpenAI-style nested
{"type": "json_schema", "json_schema": {"schema": {...}}}shape, the flatter{"type": "json_schema", "schema": {...}}shape, and{"type": "json_object"}(with or without aschema). It never modifies the schema's semantics — it raisesSchemaToGrammarErrorfor anything outside its supported subset (arrays,pattern,$ref,oneOf, mixed required/optional ordering it can't express as a simple sequential grammar, etc.) rather than silently dropping a constraint. The gateway turns that into an HTTP 422. - Unit tests:
test_schema_grammar.py(23 tests,python3 -m unittest test_schema_grammar.py). - Also fixed, and verified against a from-source build, but not shipped
in this Docker image (which uses the prebuilt
ghcr.io/ggml-org/llama.cpp:serverimage, not a source build): thethrow e;→throw;slicing bug incommon/sampling.cppdescribed above. Worth upstreaming separately; tracked here for anyone who does rebuild llama.cpp from source for this Space.
Why not upgrade or patch llama.cpp itself?
The bug is present in current llama.cpp master (verified by building
commit 84e76d8 from source), so upgrading the floating tag would not have
fixed it. Patching the tokenizer/grammar-literal mismatch correctly (so the
literal-prefix grammar tolerates the model's real detokenization) touches
shared, security-and-correctness-sensitive code used by every model's
response_format handling, and doing that safely needs llama.cpp's own
test suite and review — out of scope for a single-model gateway fix, and
unnecessary given the request-level workaround fully resolves the problem
for this deployment.
Remaining limitations
- The GBNF converter in
schema_grammar.pyonly supports the schema shapes this service's tool-repair verifier flow needs (see its module docstring). Anything else is rejected with a clear 422, not silently passed through — if broader JSON Schema support is needed later, extend_compile_node/_compile_objectand their tests. minicpm4-0.5bis small; at nonzero temperature its content judgement (e.g. therepairableboolean) can vary run-to-run even though the JSON is always syntactically valid and schema-conformant. The repair test inscripts/validate_structured_output.pyusestemperature: 0for repeatable content, which is also the right default for a verifier/repair role.start.sh's defaultMODEL_REPO(openbmb/MiniCPM4-0.5B-GGUF) does not currently resolve on the Hugging Face Hub (onlyopenbmb/MiniCPM4-0.5B-QAT-Int4-GGUFand the non-GGUFopenbmb/MiniCPM4-0.5Bexist there today). The running Space still works because the model is already cached, but a cold rebuild/restart that has to re-download the model would fail to start. This is outside this task's scope (structured output) but worth fixing separately.