diff --git a/m3_format_check/docs/m3_text_cases.md b/m3_format_check/docs/m3_text_cases.md index 0d92925..018c004 100644 --- a/m3_format_check/docs/m3_text_cases.md +++ b/m3_format_check/docs/m3_text_cases.md @@ -9,7 +9,7 @@ | 模块编号 | 模块名 | 主题 | 函数数 | |:---:|:---|:---|:---:| | 01 | basic_text | 基础文本对话(非流式) | 3 | -| 02 | sse_stream | SSE 流式协议字段 | 6 | +| 02 | sse_stream | SSE 流式协议字段 | 7 | | 03 | multiturn | 多轮对话 | 2 | | 04 | thinking | thinking 思考开关 | 4 | | 05 | sampling | 采样参数(temperature / top_p / seed) | 3 | @@ -49,6 +49,7 @@ | 02_04 | `test_02_04_stream_chunk_fields` | 流式 chunk 必带字段 | id / choices / object 全部存在 | | 02_05 | `test_02_05_text_include_usage` | `stream_options.include_usage=true` 文本场景 | 流应正常返回 usage chunk | | 02_06 | `test_02_06_stream_usage_only_in_last_chunk` | `stream_options.include_usage=true` 文本场景 | usage 非空且三字段 > 0,且只出现在流式最后一个 data chunk | +| 02_07 | `test_02_07_stream_no_utf8_replacement_char_in_emoji` | 高密度 emoji 流式请求连续执行 20 次 | 任意 `delta.content` 中都不出现 `U+FFFD`(`�`)替换字符;防止四字节 emoji 被 UTF-8 分帧从中间截断 | ## 03 multiturn — 多轮对话 @@ -253,7 +254,7 @@ --- -## 附录:parametrize 展开后的 155 个 items +## 附录:parametrize 展开后的 156 个 items 凡函数签名带 `@pytest.mark.parametrize("stream", [False, True], ids=["non_stream", "stream"])` 的会展开为 2 个 items;`max_tokens` 的两个 case 各展开为 2 个 items。 @@ -264,4 +265,4 @@ | `mt ∈ {512000, 524288}` | 06_09 | | `mt ∈ {524289, 1000000}` | 06_10 | -总 items = 116 函数 - 34 (`stream` 双值函数) - 1 (`ctx_tokens × stream` 函数) - 2 (`mt` 双值函数) + 34×2 + 1×4 + 2×2 = **155**。 +总 items = 117 函数 - 34 (`stream` 双值函数) - 1 (`ctx_tokens × stream` 函数) - 2 (`mt` 双值函数) + 34×2 + 1×4 + 2×2 = **156**。 diff --git a/m3_format_check/docs/m3_text_cases_en.md b/m3_format_check/docs/m3_text_cases_en.md index 6be6e41..4bd490c 100644 --- a/m3_format_check/docs/m3_text_cases_en.md +++ b/m3_format_check/docs/m3_text_cases_en.md @@ -9,7 +9,7 @@ | Module ID | Module Name | Theme | Functions | |:---:|:---|:---|:---:| | 01 | basic_text | Basic text conversation (non-stream) | 3 | -| 02 | sse_stream | SSE streaming protocol fields | 6 | +| 02 | sse_stream | SSE streaming protocol fields | 7 | | 03 | multiturn | Multi-turn conversation | 2 | | 04 | thinking | thinking toggle | 4 | | 05 | sampling | Sampling params (temperature / top_p / seed) | 3 | @@ -49,6 +49,7 @@ | 02_04 | `test_02_04_stream_chunk_fields` | Stream chunk required fields | id / choices / object all present | | 02_05 | `test_02_05_text_include_usage` | `stream_options.include_usage=true` (text) | Stream should return usage chunk | | 02_06 | `test_02_06_stream_usage_only_in_last_chunk` | `stream_options.include_usage=true` (text) | usage non-empty with three positive token fields, present only in the final data chunk | +| 02_07 | `test_02_07_stream_no_utf8_replacement_char_in_emoji` | Emoji-dense streaming request run 20 times | No `U+FFFD` (`�`) replacement char in any `delta.content`; guards against a 4-byte emoji being truncated mid-UTF-8-frame | ## 03 multiturn — Multi-turn conversation @@ -254,7 +255,7 @@ --- -## Appendix: 155 items after parametrize expansion +## Appendix: 156 items after parametrize expansion Functions decorated with `@pytest.mark.parametrize("stream", [False, True], ids=["non_stream", "stream"])` expand to 2 items each; the two `max_tokens` parametrized cases each expand to 2 items. @@ -265,4 +266,4 @@ Functions decorated with `@pytest.mark.parametrize("stream", [False, True], ids= | `mt ∈ {512000, 524288}` | 06_09 | | `mt ∈ {524289, 1000000}` | 06_10 | -Total items = 116 functions - 34 two-value `stream` functions - 1 `ctx_tokens × stream` function - 2 two-value `mt` functions + 34×2 + 1×4 + 2×2 = **155**. +Total items = 117 functions - 34 two-value `stream` functions - 1 `ctx_tokens × stream` function - 2 two-value `mt` functions + 34×2 + 1×4 + 2×2 = **156**. diff --git a/m3_format_check/m3_text_tests.py b/m3_format_check/m3_text_tests.py index 3aec3ae..4ae6523 100644 --- a/m3_format_check/m3_text_tests.py +++ b/m3_format_check/m3_text_tests.py @@ -150,6 +150,70 @@ def test_02_06_stream_usage_only_in_last_chunk(self): assert_oai_stream_success(r) assert_stream_usage_only_in_last_chunk(r, msg="02_06 text include_usage") + def test_02_07_stream_no_utf8_replacement_char_in_emoji(self): + """流式 delta.content 不得出现 U+FFFD(�)替换字符。 + + 背景:部分 provider 的输出链路存在 UTF-8 分帧(framing)问题, + 会把一个四字节 emoji 从字节中间截断。 + 随后服务端把非法字节转成 EF BF BD,即 Unicode 替换字符 U+FFFD(�), + 并丢弃剩余字节。因为返回的 JSON 里已经落成了 �,客户端无论如何缓存、 + 拼接都无法恢复原始 emoji,直接导致输出损坏。 + + 用大量四字节 emoji + 编号的 prompt 提高覆盖:emoji 越密集,分帧边界 + 恰好落在多字节字符中间的概率越高,越容易暴露实现里 flush 的字节截断问题。 + 连续跑 20 次,任意一次在聚合 content 或任一 delta 分片里检测到 U+FFFD + 即判失败。 + """ + prompt = ( + "请用一行输出 50 个各不相同的 emoji,格式为 '文本 N.'," + "以空格分隔,N 从 1 到 50。必须正好 50 个,不重复,不要任何解释说明。" + "尽量多使用四字节(surrogate pair)的彩色 emoji,如 😁😂🤣😊🙃🥰🤩🤔🥱🤪🧐🥺。" + ) + run_count = 20 + first_failure = None + for run_idx in range(1, run_count + 1): + result = oai_chat({ + "messages": oai_simple_messages(prompt), + "max_tokens": 4096, + }, stream=True) + assert_oai_stream_success(result) + + offenders = [] + for idx, chunk in enumerate(result.get("chunks") or []): + if not isinstance(chunk, dict): + continue + for choice_idx, choice in enumerate(chunk.get("choices") or []): + if not isinstance(choice, dict): + continue + delta = choice.get("delta") or {} + if not isinstance(delta, dict): + continue + content = delta.get("content") + if isinstance(content, str) and "\ufffd" in content: + offenders.append({ + "chunk_index": idx, + "choice_index": choice_idx, + "fffd_count": content.count("\ufffd"), + "content_preview": content[:80], + }) + + if offenders: + first_failure = { + "run": run_idx, + "offender_count": len(offenders), + "total_fffd": sum(o["fffd_count"] for o in offenders), + "samples": offenders[:3], + } + break + + assert first_failure is None, ( + f"02_07 连续 {run_count} 次中第 {first_failure['run']} 次在流式 " + f"delta.content 检测到 U+FFFD(�)替换字符,涉及 " + f"{first_failure['offender_count']} 个 chunk、共 " + f"{first_failure['total_fffd']} 个 �,疑似 UTF-8 分帧把四字节 emoji " + f"从中间截断;示例: {first_failure['samples']}" + ) + # ============================================================ # 03 multiturn — multi-turn conversation