Skip to content

Commit bfe18ec

Browse files
DragonFiveshifengmincursoragent
authored
feat: add json object constrained decoding. (#2110)
Co-authored-by: shifengmin <shifengmin@qq.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 67459ab commit bfe18ec

92 files changed

Lines changed: 7317 additions & 164 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/src/content/docs/en/features/mtp.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,40 @@ Input model references:
8888

8989
When using MTP for inference, you need to specify both the main model and the draft model (MTP model).
9090

91+
## JSON object output
92+
93+
The OpenAI-compatible Chat Completions API supports token-level JSON-object
94+
constrained decoding for models whose tokenizer can provide stable token
95+
pieces. Use:
96+
97+
```json
98+
{
99+
"response_format": {"type": "json_object"}
100+
}
101+
```
102+
103+
This mode supports ordinary generation and MTP, including streaming and
104+
non-streaming responses. With PD separation enabled, the prefill instance
105+
forwards the format flag and reasoning mode to the decode instance. The
106+
decode sequence commits and validates the first token produced by prefill
107+
before building its next-token mask.
108+
109+
The feature is model-independent at the runtime level. A request is rejected
110+
if the model tokenizer cannot provide the token pieces required to build the
111+
grammar. For models with reasoning enabled, the tokenizer must also encode the
112+
existing `</think>` boundary so the runtime can begin JSON enforcement after
113+
reasoning completes.
114+
115+
When reasoning is enabled, reasoning remains unconstrained until the model's
116+
`</think>` boundary; JSON enforcement starts afterward. When thinking is
117+
disabled through the existing chat-template kwargs, enforcement starts at the
118+
first generated token.
119+
120+
The current MVP supports only `json_object`. `json_schema`, regex/custom
121+
grammars, legacy Completion requests, C API request structs, and tool-call
122+
structural tags are not supported. Unsupported `response_format.type` values
123+
are rejected as invalid requests.
124+
91125
#### DeepSeek-V3/V3.2/R1 Launch Example
92126
```bash
93127
MODEL_PATH="/models/DeepSeek-V3"

‎docs/src/content/docs/zh/features/mtp.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,34 @@ python3 tools/export_mtp.py \
8686

8787
使用MTP进行推理时,需要同时指定主模型和草稿模型(MTP模型)。
8888

89+
## JSON 对象输出
90+
91+
OpenAI 兼容的 Chat Completions 接口支持基于 tokenizer token piece 的通用
92+
token 级 JSON 对象约束。请求中设置:
93+
94+
```json
95+
{
96+
"response_format": {"type": "json_object"}
97+
}
98+
```
99+
100+
该模式支持普通生成和 MTP,也支持流式与非流式返回。开启 PD 分离时,prefill
101+
实例会把格式标志和 reasoning 状态传递给 decode 实例;decode 侧会先提交并校验
102+
prefill 产出的第一个 token,再生成后续 token 的约束 mask。
103+
104+
该能力在 runtime 层不依赖具体模型类型。如果模型 tokenizer 无法提供构建
105+
grammar 所需的稳定 token piece,请求会被拒绝。对于启用 reasoning 的模型,
106+
tokenizer 还必须能够编码已有的 `</think>` 边界,runtime 才能在 reasoning
107+
结束后开始 JSON 约束。
108+
109+
开启 reasoning 时,`</think>` 标记之前的 reasoning token 不受 JSON 约束,标记
110+
之后才开始约束;通过现有 chat-template kwargs 关闭 thinking 时,从第一个生成
111+
token 开始约束。
112+
113+
当前 MVP 只支持 `json_object`。暂不支持 `json_schema`、正则/自定义 grammar、
114+
legacy Completion、C API 请求结构体以及 tool-call 结构化标签。其他
115+
`response_format.type` 会作为非法请求拒绝。
116+
89117
#### DeepSeek-V3/V3.2/R1 启动示例
90118
```bash
91119
MODEL_PATH="/models/DeepSeek-V3"

‎setup.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,8 @@ def finalize_options(self) -> None:
691691
super().finalize_options()
692692

693693
def run(self) -> None:
694+
global BUILD_TEST_FILE
695+
694696
build_ext_cmd = self.get_finalized_command("build_ext")
695697
build_ext_cmd.device = self.device
696698
build_ext_cmd.arch = self.arch
@@ -719,7 +721,6 @@ def run(self) -> None:
719721
path = os.path.join(root, item)
720722
if "_test" in item and os.path.isfile(path):
721723
os.remove(path)
722-
global BUILD_TEST_FILE
723724
BUILD_TEST_FILE = False
724725

725726
self.skip_build = True

‎tests/core/framework/batch/batch_packed_input_test.cpp‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ limitations under the License.
2626
#include "core/framework/block/block_manager_impl.h"
2727
#include "core/framework/model/model_input_params.h"
2828
#include "core/framework/request/stopping_checker.h"
29+
#include "core/framework/sampling/json_object_grammar.h"
2930
#include "core/runtime/forward_params.h"
3031
#include "core/runtime/params_utils.h"
3132

@@ -184,6 +185,9 @@ TEST(BatchPackedInputTest, PackedProtoLazyUnpackRestoresSampleIdxes) {
184185
builder.build_forward_input(/*num_decoding_tokens=*/1,
185186
/*min_decoding_batch_size=*/0);
186187
ASSERT_TRUE(input.sampling_params.sample_idxes.defined());
188+
input.sampling_params.filter_bitmask =
189+
torch::tensor({{static_cast<int32_t>(0x5)}},
190+
torch::TensorOptions().dtype(torch::kInt32));
187191

188192
proto::PackedForwardInput packed_input;
189193
ASSERT_TRUE(forward_input_to_packed_proto(input, &packed_input));
@@ -203,6 +207,76 @@ TEST(BatchPackedInputTest, PackedProtoLazyUnpackRestoresSampleIdxes) {
203207
ASSERT_TRUE(unpacked_input.sampling_params.sample_idxes.defined());
204208
EXPECT_TRUE(tensor_equals_vector<int32_t>(
205209
unpacked_input.sampling_params.sample_idxes, {0}));
210+
ASSERT_TRUE(unpacked_input.sampling_params.filter_bitmask.defined());
211+
EXPECT_TRUE(torch::equal(unpacked_input.sampling_params.filter_bitmask,
212+
input.sampling_params.filter_bitmask));
213+
}
214+
215+
TEST(BatchPackedInputTest, PackedProtoLazyToPreservesJsonMetadata) {
216+
RequestSamplingParam sampling_param;
217+
StoppingChecker stopping_checker;
218+
stopping_checker.set_max_generated_tokens(4);
219+
220+
SequenceParams seq_params;
221+
seq_params.seq_capacity = 32;
222+
seq_params.stopping_checker = &stopping_checker;
223+
seq_params.sampling_param = &sampling_param;
224+
seq_params.enable_schedule_overlap = true;
225+
226+
MMData mm_data;
227+
BlockManager::Options options;
228+
options.num_blocks(2).block_size(4);
229+
BlockManagerImpl manager(options);
230+
231+
IncrementalDecoder decoder("", 1, false, false);
232+
Sequence sequence(/*index=*/0,
233+
/*token_ids=*/{1, 2, 3, 4},
234+
torch::Tensor(),
235+
mm_data,
236+
std::move(decoder),
237+
seq_params);
238+
sequence.add_blocks(BlockType::KV, manager.allocate(1));
239+
240+
std::vector<Sequence*> sequences = {&sequence};
241+
std::vector<uint32_t> allowed_max_tokens = {4};
242+
BatchInputBuilder builder(sequences,
243+
allowed_max_tokens,
244+
{},
245+
{},
246+
nullptr,
247+
/*batch_id=*/2,
248+
nullptr,
249+
BatchForwardType::DECODE);
250+
ForwardInput input = builder.build_forward_input(
251+
/*num_decoding_tokens=*/1, /*min_decoding_batch_size=*/0);
252+
253+
JsonObjectGrammar grammar({"{", "}", "stop"}, /*stop_token_ids=*/{2});
254+
JsonObjectGrammarState state = grammar.initial_state();
255+
ASSERT_TRUE(state.accept_token(/*open_object=*/0));
256+
input.json_object_state_snapshots = {state.snapshot()};
257+
input.sample_sequence_ids = {"req-json#0"};
258+
input.sample_prior_output_rows = {-1};
259+
260+
proto::PackedForwardInput packed_input;
261+
ASSERT_TRUE(forward_input_to_packed_proto(input, &packed_input));
262+
263+
ForwardInput lazy_input;
264+
packed_proto_to_forward_input(
265+
packed_input, lazy_input, torch::Device(torch::kCPU), nullptr);
266+
EXPECT_TRUE(lazy_input.input_host_buffer_has_layout);
267+
EXPECT_TRUE(lazy_input.json_object_state_snapshots.empty());
268+
269+
const ForwardInput materialized_input =
270+
lazy_input.to(torch::Device(torch::kCPU), torch::kFloat32);
271+
ASSERT_EQ(materialized_input.json_object_state_snapshots.size(), 1u);
272+
EXPECT_EQ(materialized_input.json_object_state_snapshots[0].token_ids,
273+
std::vector<int32_t>({0}));
274+
EXPECT_FALSE(
275+
materialized_input.json_object_state_snapshots[0].reasoning_enabled);
276+
EXPECT_EQ(materialized_input.sample_sequence_ids,
277+
std::vector<std::string>({"req-json#0"}));
278+
EXPECT_EQ(materialized_input.sample_prior_output_rows,
279+
std::vector<int32_t>({-1}));
206280
}
207281

208282
} // namespace xllm

0 commit comments

Comments
 (0)