mirror of
https://github.com/deepseek-ai/deepseek-recipe.git
synced 2026-10-28 17:17:08 +08:00
fix(stream): match stop strings in special-token output
This commit is contained in:
parent
935817c66f
commit
4d65f489b3
@ -1016,3 +1016,41 @@ def test_sse_framing_of_the_three_responses():
|
|||||||
assert json.loads(response.to_json())["id"] == "sse"
|
assert json.loads(response.to_json())["id"] == "sse"
|
||||||
with pytest.raises(RuntimeError, match="consumed"):
|
with pytest.raises(RuntimeError, match="consumed"):
|
||||||
response_type.sse_event_name(outputs[0])
|
response_type.sse_event_name(outputs[0])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"request_type,response_type,stop_key",
|
||||||
|
[
|
||||||
|
(ChatCompletionRequest, ChatCompletionResponse, "stop"),
|
||||||
|
(MessagesRequest, MessagesResponse, "stop_sequences"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_stop_strings_match_special_tokens(request_type, response_type, stop_key):
|
||||||
|
marker = "<|ref|>"
|
||||||
|
converted = request_type({
|
||||||
|
**REQUEST, "thinking": {"type": "disabled"}, stop_key: [marker],
|
||||||
|
}).convert(ConversionOptions())
|
||||||
|
tokenizer = Tokenizer.from_file(str(TOKENIZER_PATH))
|
||||||
|
transcript = f"answer{marker}not emitted"
|
||||||
|
for tokens in (False, True):
|
||||||
|
generator = request_type.chunk_generator(converted, "stop-test", "m")
|
||||||
|
chunk = (
|
||||||
|
InferenceChunk.tokens(tokenizer.encode(transcript)) if tokens
|
||||||
|
else InferenceChunk.text(transcript)
|
||||||
|
)
|
||||||
|
outputs = run_processor(
|
||||||
|
generator, converted.parsing_options,
|
||||||
|
[InferenceChunk.ready(), chunk],
|
||||||
|
tokenizer if tokens else None,
|
||||||
|
)
|
||||||
|
response = response_type("stop-test", "m", 0, 0, 0)
|
||||||
|
for output in outputs:
|
||||||
|
response.accumulate(output)
|
||||||
|
body = json.loads(response.to_json())
|
||||||
|
if request_type is ChatCompletionRequest:
|
||||||
|
assert body["choices"][0]["message"]["content"] == "answer"
|
||||||
|
assert body["choices"][0]["finish_reason"] == "stop"
|
||||||
|
else:
|
||||||
|
assert body["content"] == [{"type": "text", "text": "answer"}]
|
||||||
|
assert body["stop_reason"] == "stop_sequence"
|
||||||
|
assert body["stop_sequence"] == marker
|
||||||
|
|||||||
@ -51,6 +51,7 @@ pub struct ParsingOptions {
|
|||||||
/// tokens.
|
/// tokens.
|
||||||
pub thinking_initial_stage: Option<ThinkingInitialStage>,
|
pub thinking_initial_stage: Option<ThinkingInitialStage>,
|
||||||
/// Match stop sequences in answer content and detected JSON output.
|
/// Match stop sequences in answer content and detected JSON output.
|
||||||
|
/// Matching uses the decoded spelling of both plain and special tokens.
|
||||||
/// Reasoning content, tool-call markup, and the text around JSON output are
|
/// Reasoning content, tool-call markup, and the text around JSON output are
|
||||||
/// excluded. The parser discards empty sequences during construction.
|
/// excluded. The parser discards empty sequences during construction.
|
||||||
pub stop_sequences: Vec<String>,
|
pub stop_sequences: Vec<String>,
|
||||||
@ -611,7 +612,14 @@ impl State {
|
|||||||
self.buffered_len += 1;
|
self.buffered_len += 1;
|
||||||
let mut matched_branch = None;
|
let mut matched_branch = None;
|
||||||
for branch in self.match_branches.0.iter_mut() {
|
for branch in self.match_branches.0.iter_mut() {
|
||||||
if branch.state.feed(byte, kind) {
|
// Stop strings constrain decoded text regardless of token kind;
|
||||||
|
// structural markers still require their original token kinds.
|
||||||
|
let match_kind = if branch.action_on_matched == OutputAction::StopSequence {
|
||||||
|
ChunkKind::Text
|
||||||
|
} else {
|
||||||
|
kind
|
||||||
|
};
|
||||||
|
if branch.state.feed(byte, match_kind) {
|
||||||
matched_branch = Some((
|
matched_branch = Some((
|
||||||
branch.state.matching_len(),
|
branch.state.matching_len(),
|
||||||
branch.next_stage,
|
branch.next_stage,
|
||||||
|
|||||||
@ -1142,4 +1142,131 @@ mod tests {
|
|||||||
assert_eq!(content(&output), "");
|
assert_eq!(content(&output), "");
|
||||||
assert_eq!(finish_chunk(&output).2.completion_tokens, 2);
|
assert_eq!(finish_chunk(&output).2.completion_tokens, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stop_sequences_match_special_token_output() {
|
||||||
|
let tokenizer = tokenizer();
|
||||||
|
let marker = "<|ref|>";
|
||||||
|
let options = || ParsingOptions {
|
||||||
|
stop_sequences: vec![marker.to_owned()],
|
||||||
|
..ParsingOptions::default()
|
||||||
|
};
|
||||||
|
let transcript = format!("answer{marker}not emitted");
|
||||||
|
let text_output = parse_texts(&[&transcript], options()).await;
|
||||||
|
assert_eq!(content(&text_output), "answer");
|
||||||
|
let ids = encode(&tokenizer, &transcript);
|
||||||
|
assert!(ids.contains(&special_token_id(&tokenizer, marker)));
|
||||||
|
let token_output = parse(
|
||||||
|
processor(options()).with_tokenizer(tokenizer),
|
||||||
|
vec![
|
||||||
|
InferenceChunk::Tokens {
|
||||||
|
token_ids: ids,
|
||||||
|
logprobs: None,
|
||||||
|
},
|
||||||
|
finish(InferenceFinishReason::Stop),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(content(&token_output), "answer");
|
||||||
|
assert_eq!(finish_chunk(&token_output).0, FinishReason::StopSequence);
|
||||||
|
assert_eq!(finish_chunk(&token_output).1, Some(marker));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stop_sequences_span_plain_and_special_tokens_at_every_token_split() {
|
||||||
|
let tokenizer = tokenizer();
|
||||||
|
let stop = "ab<|ref|>cd";
|
||||||
|
let ids = encode(&tokenizer, "aab<|ref|>cdd");
|
||||||
|
for split in 0..=ids.len() {
|
||||||
|
let options = ParsingOptions {
|
||||||
|
stop_sequences: vec![stop.to_owned()],
|
||||||
|
..ParsingOptions::default()
|
||||||
|
};
|
||||||
|
let output = parse(
|
||||||
|
processor(options).with_tokenizer(tokenizer.clone()),
|
||||||
|
vec![
|
||||||
|
InferenceChunk::Tokens {
|
||||||
|
token_ids: ids[..split].to_vec(),
|
||||||
|
logprobs: None,
|
||||||
|
},
|
||||||
|
InferenceChunk::Tokens {
|
||||||
|
token_ids: ids[split..].to_vec(),
|
||||||
|
logprobs: None,
|
||||||
|
},
|
||||||
|
finish(InferenceFinishReason::Stop),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(content(&output), "a", "split {split}");
|
||||||
|
assert_eq!(finish_chunk(&output).0, FinishReason::StopSequence);
|
||||||
|
assert_eq!(finish_chunk(&output).1, Some(stop));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn special_token_stops_stay_excluded_from_reasoning_and_tool_arguments() {
|
||||||
|
let tokenizer = tokenizer();
|
||||||
|
let marker = "<|ref|>";
|
||||||
|
let options = || ParsingOptions {
|
||||||
|
stop_sequences: vec![marker.to_owned()],
|
||||||
|
..reasoning_options()
|
||||||
|
};
|
||||||
|
let transcript = format!("hidden{marker}value</think>answer{marker}ignored");
|
||||||
|
let output = parse(
|
||||||
|
processor(options()).with_tokenizer(tokenizer.clone()),
|
||||||
|
vec![
|
||||||
|
InferenceChunk::Tokens {
|
||||||
|
token_ids: encode(&tokenizer, &transcript),
|
||||||
|
logprobs: None,
|
||||||
|
},
|
||||||
|
finish(InferenceFinishReason::Stop),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(reasoning(&output), format!("hidden{marker}value"));
|
||||||
|
assert_eq!(content(&output), "answer");
|
||||||
|
assert_eq!(finish_chunk(&output).0, FinishReason::StopSequence);
|
||||||
|
|
||||||
|
let tool_calls = format!(
|
||||||
|
r#"<|DSML|tool_calls><|DSML|invoke name="f"><|DSML|parameter name="x" string="true">a{marker}b</|DSML|parameter></|DSML|invoke></|DSML|tool_calls>"#
|
||||||
|
);
|
||||||
|
let output = parse(
|
||||||
|
processor(options()).with_tokenizer(tokenizer.clone()),
|
||||||
|
vec![
|
||||||
|
InferenceChunk::Tokens {
|
||||||
|
token_ids: encode(&tokenizer, &tool_calls),
|
||||||
|
logprobs: None,
|
||||||
|
},
|
||||||
|
finish(InferenceFinishReason::Stop),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_tool_calls(&output, &[("f", r#"{"x": "a<|ref|>b"}"#)]);
|
||||||
|
assert_eq!(finish_chunk(&output).0, FinishReason::ToolCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn special_token_stops_also_match_in_json_output() {
|
||||||
|
let tokenizer = tokenizer();
|
||||||
|
let marker = "<|ref|>";
|
||||||
|
let options = ParsingOptions {
|
||||||
|
stop_sequences: vec![marker.to_owned()],
|
||||||
|
parse_json_output: true,
|
||||||
|
..ParsingOptions::default()
|
||||||
|
};
|
||||||
|
let output = parse(
|
||||||
|
processor(options).with_tokenizer(tokenizer.clone()),
|
||||||
|
vec![
|
||||||
|
InferenceChunk::Tokens {
|
||||||
|
token_ids: encode(&tokenizer, r#"{"x":"a<|ref|>b"}"#),
|
||||||
|
logprobs: None,
|
||||||
|
},
|
||||||
|
finish(InferenceFinishReason::Stop),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(content(&output), r#"{"x":"a"#);
|
||||||
|
assert_eq!(finish_chunk(&output).0, FinishReason::StopSequence);
|
||||||
|
assert_eq!(finish_chunk(&output).1, Some(marker));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,12 @@ Completions chunks. The application supplies the backend and HTTP transport.
|
|||||||
In thinking mode, the processor counts `reasoning_tokens` for usage only when a
|
In thinking mode, the processor counts `reasoning_tokens` for usage only when a
|
||||||
tokenizer is attached; see [Use with tokenizer](tokenizer.md#decoding).
|
tokenizer is attached; see [Use with tokenizer](tokenizer.md#decoding).
|
||||||
|
|
||||||
|
Stop strings match decoded answer content and detected JSON output for both
|
||||||
|
text and token-ID input, including special-token spellings. They do not match
|
||||||
|
reasoning content or tool-call markup and arguments. Recognition of structural
|
||||||
|
markers such as the thinking terminator and DSML still respects token identity
|
||||||
|
for token-ID input.
|
||||||
|
|
||||||
## Rust
|
## Rust
|
||||||
|
|
||||||
Add the dependencies to your application:
|
Add the dependencies to your application:
|
||||||
|
|||||||
@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
`StreamProcessor` 把后端输出转换为 Messages、Chat Completions 或 Responses 事件。以下示例使用 mock 推理并打印 Chat Completions chunk。后端与 HTTP 传输由应用提供。思考模式下,只有为 processor 附加 tokenizer 后,它才会统计 usage 中的 `reasoning_tokens`,见[使用 tokenizer](tokenizer.zh.md#解码)。
|
`StreamProcessor` 把后端输出转换为 Messages、Chat Completions 或 Responses 事件。以下示例使用 mock 推理并打印 Chat Completions chunk。后端与 HTTP 传输由应用提供。思考模式下,只有为 processor 附加 tokenizer 后,它才会统计 usage 中的 `reasoning_tokens`,见[使用 tokenizer](tokenizer.zh.md#解码)。
|
||||||
|
|
||||||
|
stop 字符串会匹配解码后的正文和检测到的 JSON 输出,包括 special token 的拼写;文本输入和 token-ID 输入遵循同一规则。它不匹配思考内容、工具调用标记或参数。对于 token-ID 输入,思考结束标记和 DSML 等结构标记的识别仍依据 token 身份。
|
||||||
|
|
||||||
## Rust
|
## Rust
|
||||||
|
|
||||||
为你的应用添加依赖:
|
为你的应用添加依赖:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user