Skip to content

Commit 0cd3abb

Browse files
zerx-labclaude
andauthored
fix(byop): MiniMax M3 thinking blocks displayed as reasoning (#198)
* fix(byop): MiniMax M3 <think> blocks now displayed as reasoning (Fixes #197) 근인: MiniMax M3가 스트리밍 중 사고 내용을 /delta/reasoning_content 필드가 아닌 /delta/content 내 <think>...</think> 태그로 전송함. genai OpenAI 스트리머는 reasoning_content 필드만 ReasoningChunk로 변환하고, content 안 <think> 태그는 일반 Chunk로 흘려보내 UI가 회색 reasoning 블록 대신 <think> 태그가 포함된 일반 텍스트를 출력함. 변경 내용: - chat_stream.rs: 스트리밍 Chunk arm에 <think>...</think> 상태 머신 추가. 청크 경계에 태그가 걸쳐도 정상 처리(think_active/think_buf across iterations). <think> 내부 → ReasoningChunk 경로로 라우팅 (회색 reasoning 블록 표시 + note_reasoning_seen latch 설정), 외부 콘텐츠 → 기존 Text 경로 유지. - reasoning.rs: INTERLEAVED_RULES에 ("minimax-m3", RC) 추가. M3가 reasoning_content를 반환하는 멀티턴 대화에서 두 번째 턴의 echo 누락으로 인한 400 오류 방지. 수정 파일: 2 / 행수: 66줄 순증가 / 영향면: 1곳(Chunk arm) https://claude.ai/code/session_0123ysTwn44jVs63dELnqxib * fix(byop): 리뷰 반영 — <think> 추출 범위를 MiniMax M3로 제한 리뷰 코멘트 대응: 1. [블로킹] <think> 추출이 전체 BYOP 모델에 전역 적용되던 문제 수정: - reasoning.rs: THINK_TAG_IN_CONTENT_MODELS 상수와 model_uses_think_tags_in_content() 함수 추가 - chat_stream.rs: use_think_extraction 게이트 도입. Chunk arm을 if/else 브랜치로 분리해 MiniMax M3만 상태머신 활성화, 나머지 모델은 기존 단순 텍스트 emit 경로 유지. GPT-4o / Qwen 등 일반 모델이 리터럴 <think> 텍스트를 출력해도 reasoning 채널로 잘못 라우팅되는 부작용 제거. 2. [질문] MiniMax M3 멀티턴 echo 포맷 불확실: - reasoning.rs: INTERLEAVED_RULES에서 ("minimax-m3", RC) 항목 제거. M3의 다음턴 assistant message echo 포맷(RC 필드 vs <think>-in-content)이 확인되지 않아 잘못된 RC 설정을 남기지 않도록 제거. 에코 수정은 포맷 확인 후 별도 PR로 처리. - chat_stream.rs: <think> 추출 arm에서 note_reasoning_seen() 제거. RC echo 라치를 잘못 설정하지 않도록 방지. 3. [비블로킹] <think> 태그 문자열 자체가 chunk 경계에서 절단되는 known limitation 주석 추가. 4. reasoning.rs에 model_uses_think_tags_in_content 단위 테스트 추가. https://claude.ai/code/session_0123ysTwn44jVs63dELnqxib --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 940c158 commit 0cd3abb

2 files changed

Lines changed: 132 additions & 8 deletions

File tree

app/src/ai/agent_providers/chat_stream.rs

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3203,6 +3203,9 @@ pub async fn generate_byop_output(
32033203
} = input;
32043204

32053205
let force_echo_reasoning = super::reasoning::model_requires_reasoning_echo(api_type, &model_id);
3206+
// 仅对已知把 reasoning 夹在 <think> 标签里的模型(如 MiniMax M3)激活流式提取。
3207+
// 其他模型保持原始 Chunk 输出行为,避免误吞含字面量 <think> 的正常文本。
3208+
let use_think_extraction = super::reasoning::model_uses_think_tags_in_content(&model_id);
32063209
let chat_req = build_chat_request(&params, force_echo_reasoning, api_type, attachment_caps)?;
32073210
let conversation_id = params
32083211
.conversation_token
@@ -3545,6 +3548,10 @@ pub async fn generate_byop_output(
35453548
// 之后的 chunk 走 AppendToMessageContent 增量追加。
35463549
let mut text_msg_id: Option<String> = None;
35473550
let mut reasoning_msg_id: Option<String> = None;
3551+
// <think>...</think> 流式提取状态:仅当 `use_think_extraction` 为 true 时有意义。
3552+
// 已知把 reasoning 夹在 <think> 标签里的模型(如 MiniMax M3)用此提取。
3553+
let mut think_active = false;
3554+
let mut think_buf = String::new();
35483555
// tool_call 按 call_id 累积 — genai 流式发的 ToolCallChunk 已带完整 ToolCall
35493556
// (since 0.4.0 行为),但跨 chunk 同一 call_id 可能多次出现 args 增量,
35503557
// 用 HashMap 按 id 累积后在流末统一 emit。
@@ -3629,14 +3636,89 @@ pub async fn generate_byop_output(
36293636
ChatStreamEvent::Chunk(c) if !c.content.is_empty() => {
36303637
chunk_count += 1;
36313638
chunk_bytes += c.content.len();
3632-
if let Some(id) = text_msg_id.clone() {
3633-
yield Ok(make_append_event(&current_task_id, &id, AppendKind::Text(c.content)));
3639+
if use_think_extraction {
3640+
// <think> 标签流式提取:仅对 THINK_TAG_IN_CONTENT_MODELS 白名单内的模型激活。
3641+
// 把 /delta/content 中的 <think>...</think> 段路由到 reasoning 通道,
3642+
// 其余内容照常走文本通道。支持标签内容跨 chunk 边界。
3643+
//
3644+
// known limitation: `<think>` 标签字符串本身跨 chunk 截断时(如
3645+
// chunk1 末尾为 `<thi`、chunk2 开头为 `nk>`)无法识别,残余字符串
3646+
// 作为普通文本输出。大多数推理模型会把 `<think>` 作为完整 token 输出,
3647+
// 实际触发概率极低。
3648+
let mut rest: &str = &c.content;
3649+
loop {
3650+
if think_active {
3651+
match rest.find("</think>") {
3652+
Some(end) => {
3653+
think_buf.push_str(&rest[..end]);
3654+
let reasoning = std::mem::take(&mut think_buf);
3655+
think_active = false;
3656+
rest = &rest[end + "</think>".len()..];
3657+
if !reasoning.is_empty() {
3658+
reasoning_count += 1;
3659+
reasoning_bytes += reasoning.len();
3660+
if let Some(id) = reasoning_msg_id.clone() {
3661+
yield Ok(make_append_event(&current_task_id, &id, AppendKind::Reasoning(reasoning)));
3662+
} else {
3663+
let new_id = Uuid::new_v4().to_string();
3664+
let mut msg = make_reasoning_message(&current_task_id, &request_id, reasoning);
3665+
msg.id = new_id.clone();
3666+
reasoning_msg_id = Some(new_id);
3667+
yield Ok(make_add_messages_event(&current_task_id, vec![msg]));
3668+
}
3669+
}
3670+
}
3671+
None => {
3672+
think_buf.push_str(rest);
3673+
break;
3674+
}
3675+
}
3676+
} else {
3677+
match rest.find("<think>") {
3678+
Some(start) => {
3679+
let before = rest[..start].to_owned();
3680+
think_active = true;
3681+
rest = &rest[start + "<think>".len()..];
3682+
if !before.is_empty() {
3683+
if let Some(id) = text_msg_id.clone() {
3684+
yield Ok(make_append_event(&current_task_id, &id, AppendKind::Text(before)));
3685+
} else {
3686+
let new_id = Uuid::new_v4().to_string();
3687+
let mut msg = make_agent_output_message(&current_task_id, &request_id, before);
3688+
msg.id = new_id.clone();
3689+
text_msg_id = Some(new_id);
3690+
yield Ok(make_add_messages_event(&current_task_id, vec![msg]));
3691+
}
3692+
}
3693+
}
3694+
None => {
3695+
let text = rest.to_owned();
3696+
if !text.is_empty() {
3697+
if let Some(id) = text_msg_id.clone() {
3698+
yield Ok(make_append_event(&current_task_id, &id, AppendKind::Text(text)));
3699+
} else {
3700+
let new_id = Uuid::new_v4().to_string();
3701+
let mut msg = make_agent_output_message(&current_task_id, &request_id, text);
3702+
msg.id = new_id.clone();
3703+
text_msg_id = Some(new_id);
3704+
yield Ok(make_add_messages_event(&current_task_id, vec![msg]));
3705+
}
3706+
}
3707+
break;
3708+
}
3709+
}
3710+
}
3711+
}
36343712
} else {
3635-
let new_id = Uuid::new_v4().to_string();
3636-
let mut msg = make_agent_output_message(&current_task_id, &request_id, c.content);
3637-
msg.id = new_id.clone();
3638-
text_msg_id = Some(new_id);
3639-
yield Ok(make_add_messages_event(&current_task_id, vec![msg]));
3713+
if let Some(id) = text_msg_id.clone() {
3714+
yield Ok(make_append_event(&current_task_id, &id, AppendKind::Text(c.content)));
3715+
} else {
3716+
let new_id = Uuid::new_v4().to_string();
3717+
let mut msg = make_agent_output_message(&current_task_id, &request_id, c.content);
3718+
msg.id = new_id.clone();
3719+
text_msg_id = Some(new_id);
3720+
yield Ok(make_add_messages_event(&current_task_id, vec![msg]));
3721+
}
36403722
}
36413723
}
36423724
ChatStreamEvent::Chunk(_) => {}

app/src/ai/agent_providers/reasoning.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,11 @@ const INTERLEAVED_RULES: &[(&str, ReasoningInterleavedField)] = {
250250
("glm-4.5-thinking", RC),
251251
("glm-4.6-thinking", RC),
252252
("glm-4.7", RC),
253-
// MiniMax M1 thinking
253+
// MiniMax M1 thinking(使用 reasoning_content 字段)
254254
("minimax-m1", RC),
255+
// MiniMax M3: reasoning 以 <think> 标签夹在 content 中传输,
256+
// 多轮 echo 格式待确认(RC 还是 <think>-in-content),暂不加 RC 条目。
257+
// 显示修复由 model_uses_think_tags_in_content 白名单 + 流式提取处理。
255258
// 腾讯混元 T1 thinking
256259
("hunyuan-t1", RC),
257260
// 百度文心 X1 / thinking
@@ -268,6 +271,27 @@ const INTERLEAVED_RULES: &[(&str, ReasoningInterleavedField)] = {
268271
]
269272
};
270273

274+
/// OpenAI 兼容 thinking 模型中,把 reasoning 以 `<think>...</think>` 标签形式夹在
275+
/// `/delta/content` 里传输(而非独立的 `/delta/reasoning_content` 字段)的模型白名单。
276+
///
277+
/// 命中此表的模型,chat_stream 流式层会对 Chunk 事件做 `<think>` 标签提取,
278+
/// 把标签内内容路由到 reasoning 通道显示为灰色思考块。
279+
/// 未命中的模型保持原有文本输出行为,避免误吞含字面量 `<think>` 的正常输出。
280+
const THINK_TAG_IN_CONTENT_MODELS: &[&str] = &[
281+
// MiniMax M3:reasoning 通过 content 中的 <think> 标签传输。
282+
"minimax-m3",
283+
];
284+
285+
/// 返回指定模型是否通过 `<think>` 标签在 content 中传递 reasoning(而非 reasoning_content 字段)。
286+
///
287+
/// chat_stream 流式层用此函数决定是否对 Chunk 事件做 `<think>` 标签提取。
288+
pub fn model_uses_think_tags_in_content(model_id: &str) -> bool {
289+
let id = model_id.to_ascii_lowercase();
290+
THINK_TAG_IN_CONTENT_MODELS
291+
.iter()
292+
.any(|&needle| id.contains(needle))
293+
}
294+
271295
/// 运行时 latch 集合:记录哪些 (api_type, model_id) 在某次 stream 里发过
272296
/// `ReasoningChunk` —— 即"该 endpoint 服务端认识 reasoning_content 字段"的
273297
/// 精准启发式信号。
@@ -821,4 +845,22 @@ mod tests {
821845
"qwq-32b"
822846
));
823847
}
848+
849+
#[test]
850+
fn think_tag_in_content_models() {
851+
// MiniMax M3 命中
852+
assert!(model_uses_think_tags_in_content("minimax-m3"));
853+
assert!(model_uses_think_tags_in_content("MiniMax-M3-80k"));
854+
assert!(model_uses_think_tags_in_content("MINIMAX-M3"));
855+
// MiniMax M1 不命中(使用 reasoning_content 字段)
856+
assert!(!model_uses_think_tags_in_content("minimax-m1"));
857+
// 其他 thinking 模型不命中(各自走 reasoning_content 字段)
858+
assert!(!model_uses_think_tags_in_content("deepseek-r1"));
859+
assert!(!model_uses_think_tags_in_content("gpt-5"));
860+
assert!(!model_uses_think_tags_in_content("qwen3-235b"));
861+
assert!(!model_uses_think_tags_in_content("kimi-k2-thinking"));
862+
// 普通非 thinking 模型不命中
863+
assert!(!model_uses_think_tags_in_content("gpt-4o"));
864+
assert!(!model_uses_think_tags_in_content("claude-opus-4-7"));
865+
}
824866
}

0 commit comments

Comments
 (0)