I had three vllm serve processes running on the H200 cluster, one prompt piped into all three through the same OpenAI-compatible client, and I was watching what should have been identical scaffolding produce three different failure modes. gpt-oss-120b got a single H200 to itself, MXFP4 weights comfortably inside the 141GB of HBM3e with room left for KV cache. Qwen3.8-27B got carved out of a shared card instead, a 64GB MIG-style fraction, enough for the dense BF16 weights plus a reasonable context budget without needing a whole GPU dedicated to a model a third the size. The first stream came back clean, reasoning_content and content landing in separate deltas exactly the way the OpenAI SDK expects. The second stream came back for a while and then the client threw json.decoder.JSONDecodeError: Unexpected control character, and the UI just stopped rendering mid-sentence. The third stream didn't error, but it printed the model's entire internal monologue straight into the answer, <think> tag and all, because nothing downstream knew that string of characters was supposed to mean something.
The clean one was gpt-oss-120b. The other two were Qwen2.5-Coder-32B-Instruct and the new Qwen3.8-27B, and neither failure had anything to do with model quality. It was a serving-layer problem, and it lived entirely in how each model's chat template decides to hand you its reasoning trace.
gpt-oss ships with the harmony response format baked in, and vLLM's --reasoning-parser openai_gptoss (paired with the harmony chat template) already knows how to split the analysis channel from the final channel before it ever hits the wire. You get reasoning_content and content as two clean fields on the delta object, no regex, no buffering, no guessing where one ends and the other starts. That's the entire reason the first window behaved. OpenAI designed the model and the serving format for it at the same time, so the seam is invisible.
Qwen's reasoning models don't get that seam for free. Both Qwen2.5-Coder and Qwen3.8-27B emit a plain <think>...</think> block inline in the content stream, plain text, no separate channel, and vLLM only knows how to split it if you launch with the matching --reasoning-parser (I ended up on deepseek_r1-style tag matching for one and a Qwen3-specific parser for the other, because the two models don't tokenize or terminate the block identically). Get the flag wrong, or run an older vLLM build that predates that model's parser, and the tag just rides along in content as raw text. That's survivable if the whole response arrives in one shot. It stops being survivable the second you're streaming over SSE, because the opening <think> can land in delta seventeen and the closing </think> can land in delta forty-one, with the model happily emitting unescaped newlines, literal double quotes, and stray backslashes in between, since as far as the model is concerned it's just writing text, not authoring a JSON payload. Every one of those characters is a JSON control character if it lands unescaped inside a string field, which is exactly what was throwing JSONDecodeError on my end. The chat client wasn't failing to render the answer. It was failing to parse the transport frame carrying the answer, which looks identical from the outside and is a much worse afternoon to debug.
The fix was a small interceptor sitting in front of vLLM's /v1/chat/completions SSE endpoint, proxying every data: line before it reaches the client. It's a tiny state machine: buffer incoming deltas until you've accumulated enough text to safely check for a <think> open or close boundary (you can't just regex each chunk independently, because the tag itself can be split across chunk boundaries), track which side of the boundary you're currently on, and re-emit two well-formed SSE events instead of one, each with its own json.dumps-escaped payload, one tagged as reasoning and one as content. Maybe forty lines of Python. It took longer to get right than either model took to cold-load off local NVMe, mostly because Qwen2.5-Coder and Qwen3.8-27B don't agree on tool-call formatting either, so the same interceptor had to branch on model family: Hermes-style function-call JSON for one, Qwen3's native tool-call parser for the other. One regex covering both would've silently mangled function-calling output on whichever model it wasn't tuned for, and I only caught that because a tool call came back with truncated arguments in testing.
None of that plumbing tells you which model is actually better, so it's worth being straight about what these two are under the hood. gpt-oss-120b is a sparse MoE transformer: 36 layers, 128 experts per layer, top-4 routing, 116.8B total parameters but only about 5.1B active per token. Alternating dense and locally-banded sparse attention, GQA with 64 query heads and 8 KV heads, native MXFP4 quantization on the MoE weights, which is the whole reason it fits on a single 80GB H100 with room to spare and pushes real throughput at that footprint. Qwen3.8-27B takes the opposite bet: dense, 27.8B parameters, every one of them active on every forward pass, no router, no gating. Architecturally it's a hybrid stack, sixteen repeats of three Gated DeltaNet-plus-FFN blocks followed by one Gated Attention-plus-FFN block, 5120 hidden dim across 64 layers, natively multimodal with a vision encoder bolted onto the same causal LM, and a 262,144-token native context window that YaRN stretches to a million. gpt-oss has none of that; it's text-only.

If you're optimizing for tokens per second per GPU-dollar, gpt-oss-120b wins and it isn't close. Sparse activation means you're paying compute for a 5B model while carrying the knowledge of a 117B one, and the harmony format means zero afternoons spent writing SSE interceptors. But throughput isn't the only axis I care about on a box that's serving one agentic loop end-to-end instead of a queue of concurrent strangers. In long tool-calling chains, twenty, thirty steps deep, where the model has to keep several constraints live in context while correcting itself against new tool output, I keep reaching for the dense model. There's no router making a marginally different expert-selection call on token forty than it made on token four; every parameter is present for every step, which shows up as fewer of the small coherence failures that compound across a long chain. That consistency is a big part of why Qwen3.8-27B, at roughly a quarter of gpt-oss-120b's parameter count and with all of them active, is the model people are currently pointing to as the open-weight release that tracks closest to what the frontier commercial labs ship, benchmarked against agentic coding and computer-use suites where its scores jumped hard over Qwen3.6-27B without any increase in published decoder size. Not because it's bigger. Because density buys a kind of per-step consistency that MoE routing trades away for speed.
I don't think there's a clean winner, and I'd be skeptical of anyone who hands you one without qualifying it by workload. What's running on my desk now is a router of my own construction: gpt-oss-120b behind the proxy for anything high-volume and tolerant of a little per-call variance, Qwen3.8-27B for anything that has to stay coherent across a long chain of its own tool calls, and forty-odd lines of Python sitting quietly in between, making sure that whichever model is talking, its quotes are escaped and its channels are split before the client ever has to find out the hard way.