diff --git a/docs/DeepSeekSSE流格式字段分析-2026-04-03.md b/docs/DeepSeekSSE流格式字段分析-2026-04-03.md deleted file mode 100644 index 5078e62..0000000 --- a/docs/DeepSeekSSE流格式字段分析-2026-04-03.md +++ /dev/null @@ -1,107 +0,0 @@ -# DeepSeek SSE 流格式字段分析(永久默认样本) - -> 日期:2026-04-05(UTC) -> -> 默认样本: -> - `tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260404/upstream.stream.sse` -> - `tests/raw_stream_samples/content-filter-trigger-20260405-jwt3/upstream.stream.sse` -> -> 模型:`deepseek-reasoner-search`(搜索 + 思考) - -## 1. SSE 事件层结构 - -原始流由标准 SSE 帧组成,常见形态: - -```text -event: -data: - -``` - -样本中主要 `event` 类型: - -- `ready`:流建立后返回请求/响应消息 ID。 -- `update_session`:会话时间戳更新。 -- `finish`:流式阶段结束。 -- (无 `event` 时)默认为 message 事件,`data:` 中承载主要增量数据。 - -## 2. `data` JSON 常见字段 - -上游增量主体多为 JSON Patch 风格对象: - -- `p`(path):字段路径,如 `response/fragments/-1/content`。 -- `o`(op,可选):操作类型,常见 `SET` / `APPEND` / `BATCH`。 -- `v`(value):值(字符串、布尔、对象、数组都可能)。 - -示例(语义): - -- `{"p":"response/fragments/-1/content","o":"APPEND","v":"..."}` -- `{"p":"response/fragments/-16/status","v":"FINISHED"}` -- `{"p":"response/status","o":"SET","v":"FINISHED"}` - -## 3. 搜索+思考场景关键路径 - -### 3.1 文本内容 - -- `response/fragments//content` -- `response/content` -- `response/thinking_content` -- `response/fragments`(`APPEND` + fragment 数组) - -### 3.2 搜索相关 - -- `response/fragments//results`(检索结果数组) -- `response/search_status`(检索状态,建议跳过展示) - -### 3.3 状态相关(重点) - -- `response/status = FINISHED`:**最终结束信号**(需要保留用于结束判定) -- `response/fragments//status = FINISHED`:**分片级状态**(高频,建议跳过输出) -- `response/quasi_status`:过程状态(建议跳过输出) - -## 4. 泄露问题根因(FINISHED 重复) - -在搜索 + 思考模型中,`response/fragments//status` 会出现大量不同 ``(例如 `-1/-2/-3/-16...`)的 `FINISHED`。 - -若只过滤固定少量索引(例如仅 `-1/-2/-3`),其他索引的状态会当普通文本透传,导致前端出现: - -- `FINISHEDFINISHEDFINISHED...` - -## 5. 适配建议(已落地) - -1. 跳过所有 `response/fragments/-?\d+/status`。 -2. 继续保留 `response/status=FINISHED` 作为真正结束判定。 -3. 通过独立仿真工具持续回放 manifest 声明的 canonical 默认样本,作为回归门禁: - -```bash -./tests/scripts/run-raw-stream-sim.sh -``` - -如果需要新增永久样本,可以直接走本地专用接口: - -```bash -POST /admin/dev/raw-samples/capture -``` - -它只会把请求元信息和原始上游流落到 `tests/raw_stream_samples//`。项目最终输出不会再写入样本目录,而是留给本地回放工具按需生成,写到 `artifacts/raw-stream-sim///` 里做对照和查阅。 - -## 6. `CONTENT_FILTER` 终态样本 - -在 `content-filter-trigger-20260405-jwt3` 样本中,末尾会出现一组明确的风控终态字段: - -- `response.status = CONTENT_FILTER` -- `response.quasi_status = CONTENT_FILTER` -- `response.fragments` 里包含 `TEMPLATE_RESPONSE` 拒答文案 -- 后续仍会有 `event: finish` - -这说明: - -1. 风控不是“没有结束信号”,而是“正常流式输出后在尾部切换到风控终态”。 -2. 适配层不能把 `TEMPLATE_RESPONSE` 当普通正文输出。 -3. 回放工具需要把这种终态保留下来,用于后续回归和字段分析。 - -## 7. 后续扩展建议 - -- 增加不同模型(`deepseek-chat-search` / 非 search / 非 thinking)样本。 -- 增加异常样本(限流、中断、content_filter、空结果)。 -- 为仿真报告加入字段覆盖率统计(路径频次、事件频次、终止路径命中率)。 diff --git a/docs/DeepSeekSSE行为结构说明-2026-04-05.md b/docs/DeepSeekSSE行为结构说明-2026-04-05.md new file mode 100644 index 0000000..dce0ba7 --- /dev/null +++ b/docs/DeepSeekSSE行为结构说明-2026-04-05.md @@ -0,0 +1,283 @@ +# DeepSeek SSE 行为结构说明(第三方逆向版) + +> 说明:本文基于当前仓库 `tests/raw_stream_samples/` 下全部 `upstream.stream.sse` 原始流样本整理而成,属于第三方逆向观察文档,不是官方协议。 +> 当前 corpus 由 4 份原始流组成,覆盖搜索+引用、风控终态、Markdown 输出和空格敏感输出等行为。 + +## 1. 样本覆盖 + +下列样本共同构成了本文的观察基础: + +| 样本 | 观察重点 | +| --- | --- | +| [guangzhou-weather-reasoner-search-20260404](../tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260404/upstream.stream.sse) | 搜索+思考流程,包含 `reference:N` 引用标记与工具片段 | +| [content-filter-trigger-20260405-jwt3](../tests/raw_stream_samples/content-filter-trigger-20260405-jwt3/upstream.stream.sse) | `CONTENT_FILTER` 终态分支,包含拒答模板与 `ban_regenerate` | +| [markdown-format-example-20260405](../tests/raw_stream_samples/markdown-format-example-20260405/upstream.stream.sse) | Markdown 输出的早期样本,用于观察 token 级输出形态 | +| [markdown-format-example-20260405-spacefix](../tests/raw_stream_samples/markdown-format-example-20260405-spacefix/upstream.stream.sse) | Markdown 输出修正样本,用于验证空格 chunk 必须保留 | + +当前 corpus 的整体特征是 `message` 帧占绝对多数,控制事件只占很小一部分,但它们决定了流的生命周期和最终状态。 + +## 2. 总体结构 + +DeepSeek 的这类输出可以分成两层看: + +1. SSE 事件层。 +2. JSON 载荷层。 + +事件层负责传输边界,载荷层负责业务状态。实现时不要把 HTTP chunk、SSE block 和业务 JSON 混为一体。 + +最常见的时序可以概括为: + +```text +ready +update_session +message(初始化 envelope) +message(正文 / 片段 / 状态增量) +message(状态收口) +finish +update_session +title +close +``` + +`finish` 表示生成流结束,但不是唯一的终止信号;真正的语义终态通常还要结合 `response/status`、`quasi_status` 和 `close` 一起判断。 + +## 3. SSE 事件层 + +当前 corpus 中观察到的事件类型如下: + +| 事件 | 作用 | 处理建议 | +| --- | --- | --- | +| `ready` | 传输层就绪,通常携带 `request_message_id`、`response_message_id`、`model_type` | 记录元数据即可,不参与正文拼接 | +| `update_session` | 会话时间戳或心跳更新 | 当作会话状态帧处理 | +| `message` | 主体载荷,绝大多数业务信息都在这里 | 必须按顺序解析并保序累积 | +| `finish` | 生成阶段结束 | 作为流结束标记之一 | +| `title` | 会话标题生成结果 | 元数据帧,不参与正文拼接 | +| `close` | 连接关闭信息 | 仅用于收尾与审计 | + +说明: + +- `message` 是默认事件名,SSE 中没有显式 `event:` 时也应按 `message` 处理。 +- 目前样本里大量 `message` 帧没有独立的业务前缀,不能靠事件名区分正文和控制帧。 +- 可能出现空 payload 的 `message` 帧;它们应被视为 no-op,但不能打乱事件顺序。 + +## 4. 载荷层形态 + +`message` 的 `data:` 部分不是单一 schema,而是多种结构混合。当前 corpus 里主要见到以下几种形态: + +| 形态 | 典型结构 | 作用 | +| --- | --- | --- | +| 初始化 envelope | `{"v":{"response":{...}}}` | 给出会话初始状态、模型状态和片段容器 | +| 纯文本 token | `{"v":"..."}` | 直接输出可见文本 token | +| 路径补丁 | `{"p":"...","o":"APPEND|SET|BATCH","v":...}` | 对某个状态路径做增量更新 | +| 终态 batch | `{"v":[{"p":"status","v":"CONTENT_FILTER"}, ...]}` | 尾部状态收口,常见于风控终态 | + +一个简化后的典型样式如下: + +```json +{"v":"输出"} +{"p":"response/fragments/-1/content","o":"APPEND","v":"..."} +{"p":"response/fragments","o":"APPEND","v":[...]} +{"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":211},{"p":"quasi_status","v":"FINISHED"}]} +{"p":"response/status","o":"SET","v":"FINISHED"} +``` + +注意: + +- `v` 可能是字符串、对象、数组、布尔值或数字。 +- `o` 当前样本里主要见到 `APPEND`、`SET`、`BATCH`。 +- `v` 为数组时,通常表示一个批量 patch 集合,而不是正文数组。 + +## 5. 初始化 envelope + +每条流开头,常会先出现一个 `message` 帧,内容是完整的 `response` 初始状态。当前 corpus 中,这个 envelope 常见字段包括: + +- `message_id` +- `parent_id` +- `model` +- `role` +- `thinking_enabled` +- `ban_edit` +- `ban_regenerate` +- `status` +- `incomplete_message` +- `accumulated_token_usage` +- `files` +- `feedback` +- `inserted_at` +- `search_enabled` +- `fragments` +- `conversation_mode` +- `has_pending_fragment` +- `auto_continue` +- `search_triggered` + +这些字段更像会话状态和策略开关,不是正文内容。第三方实现应把它们保留在内部状态树里,而不是直接拼接到最终答案。 + +## 6. 路径结构 + +当前 corpus 里观察到的 `p` 路径可以归成几组: + +### 6.1 片段级路径 + +- `response/fragments/-N/content` +- `response/fragments/-N/status` +- `response/fragments/-N/results` +- `response/fragments/-N/elapsed_secs` + +这类路径表示某个片段对象的增量更新。`-N` 只是样本中的索引风格,不应被写死成固定数量。 + +### 6.2 片段容器路径 + +- `response/fragments` +- `fragments` + +这两类路径通常承载 fragment 数组。前者更像响应树中的分支,后者更像终态批处理里的片段集合。 + +### 6.3 语义状态路径 + +- `response/status` +- `response/has_pending_fragment` +- `quasi_status` +- `status` +- `ban_regenerate` + +这类路径决定流是否结束、是否被风控、是否还有待处理片段。它们不应作为正文输出。 + +### 6.4 统计与进度路径 + +- `accumulated_token_usage` + +这类路径用于使用量或进度统计,属于元数据。 + +### 6.5 非命名空间字段 + +在片段对象内部,还会看到 `content`、`references`、`result`、`queries`、`stage_id` 等字段。它们不一定带 `response/...` 前缀,但仍然是协议语义的一部分。 + +## 7. fragment 类型 + +当前 corpus 里已经观察到的 fragment 类型如下: + +| 类型 | 作用 | 是否应直接渲染 | +| --- | --- | --- | +| `RESPONSE` | 正常回答片段 | 是,属于正文 | +| `THINK` | 推理或阶段提示 | 通常否,按产品策略决定是否展示 | +| `TOOL_SEARCH` | 搜索工具调用元数据 | 否 | +| `TOOL_OPEN` | 打开 / 抽取结果的工具元数据 | 否 | +| `TIP` | 提示 / 警告类片段,常带 `style: WARNING` | 视产品策略决定,通常作为附注 | +| `TEMPLATE_RESPONSE` | 风控拒答模板 | 是,但它属于终态 fallback,不是普通正文 | + +观察到的典型片段字段: + +- `id` +- `type` +- `content` +- `references` +- `stage_id` +- `status` +- `queries` +- `results` +- `result` +- `elapsed_secs` +- `style` +- `hide_on_wip` + +第三方实现不要把 `fragment.type` 和 `p` 路径混为一谈。`type` 是语义分类,`p` 是状态树位置。 + +## 8. 终态行为 + +当前 corpus 里有两条很重要的终态分支。 + +### 8.1 正常完成 + +正常回答通常会出现如下收口顺序: + +1. `response` 的 `BATCH` 更新 `accumulated_token_usage`。 +2. `response` 的 `BATCH` 或单独 patch 更新 `quasi_status: FINISHED`。 +3. `response/status` 置为 `FINISHED`。 +4. `finish` 事件到来。 +5. 之后可能还有 `update_session`、`title`、`close`。 + +### 8.2 风控终态 + +`content-filter-trigger-20260405-jwt3` 展示了另一种终态路径: + +1. 先继续输出一段正常正文。 +2. 出现提示类 fragment,例如 `TIP`。 +3. 可能先把 `quasi_status` 提前收口到 `FINISHED`。 +4. 之后出现一个终态 batch,把 `ban_regenerate` 设为 `true`,把 `status` 置为 `CONTENT_FILTER`,并附带 `TEMPLATE_RESPONSE`。 +5. 最后再出现 `finish`,然后是收尾事件。 + +这个分支说明: + +- `finish` 不等于正常结束。 +- `CONTENT_FILTER` 是一个独立终态,不是普通异常。 +- `TEMPLATE_RESPONSE` 不应被当作常规回答流的中间片段,它是终态 fallback。 + +一个简化的风控尾部可以写成: + +```json +{"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":1269},{"p":"quasi_status","v":"FINISHED"}]} +{"v":[{"p":"ban_regenerate","v":true},{"p":"status","v":"CONTENT_FILTER"},{"p":"fragments","v":[{"id":38,"type":"TEMPLATE_RESPONSE","content":"..."}]},{"p":"quasi_status","v":"CONTENT_FILTER"}]} +{"event":"finish"} +``` + +## 9. 文本重建规则 + +如果你的目标是把流重建成最终可见文本,必须遵守下面这些规则: + +- 按接收顺序逐个追加 token。 +- 不要对每个 `v` 做 `trim` 或 `TrimSpace`。 +- 不要丢弃只包含空格的 chunk。 +- 不要合并连续空格、换行或 Markdown 符号附近的空白。 +- 不要把 `[reference:N]` 视为协议元数据,它在当前 corpus 里就是正文的一部分。 +- 如果你要屏蔽引用标记,应当把它做成可配置的后处理,而不是在解析阶段硬删。 + +这点对 Markdown、代码块、引用、表格都很关键。样本里已经证明,`#`、`-`、`>`、`|` 这类符号后面的空格必须原样保留,否则渲染结果会变形。 + +## 10. 推荐实现方式 + +对第三方开发者,建议把实现拆成三条线: + +1. 原始事件线:保留 SSE block 顺序、事件名和完整 JSON 载荷。 +2. 状态树线:维护 `response`、`fragments`、`status`、`quasi_status` 等结构。 +3. 可见文本线:只从明确应渲染的 token / fragment 中拼接最终文本。 + +一个简单的处理顺序可以是: + +```text +parse SSE block + -> 识别 event + -> 解析 JSON payload + -> 更新状态树 + -> 判定是否有可见文本 + -> 追加到输出缓冲 + -> 遇到 FINISHED / CONTENT_FILTER / finish 时收口 +``` + +实现时的兼容原则: + +- 未知路径保留,不要报错中断。 +- 未知 fragment.type 保留在日志里。 +- 不要假设所有模型都一定输出 `thinking_content`,当前 corpus 的推理更多是通过 fragment 类型表达。 +- 不要假设 `title` 一定存在,它只是后置元数据。 + +## 11. 本 corpus 证明了什么 + +当前样本足以证明以下行为: + +- 搜索类模型会把工具调用、结果、引用和正文混在同一条 SSE 流里。 +- 风控不会简单地“没有输出”,而是会在正常生成后切换到 `CONTENT_FILTER` 终态。 +- Markdown 和代码输出对空格非常敏感,空格 chunk 不能吞。 +- `message` 是主体承载层,`ready` / `update_session` / `finish` / `title` / `close` 是控制层。 +- `fragment.type` 是可视化和工具链分层的关键,不应只靠 `p` 路径判断。 + +## 12. 适用边界 + +本文是基于当前 corpus 的逆向说明,不是恒定协议。 + +- 新模型可能增加新的 `p` 路径。 +- 新版本可能增加新的 fragment.type。 +- `CONTENT_FILTER` 的终态模板内容可能变化。 +- 解析器应当对未知字段、未知路径、未知事件保持容忍。 + +如果你要把这份说明用于实际开发,建议同时保留原始流样本、回放脚本和回归测试,不要只依赖本文。 diff --git a/docs/TESTING.md b/docs/TESTING.md index c36cda2..6975de5 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -237,6 +237,7 @@ go run ./cmd/ds2api-tests --no-preflight - 默认校验不出现 `FINISHED` 文本泄露,并要求存在结束信号。 - 每次运行都会把本地派生结果写入 `artifacts/raw-stream-sim///replay.output.txt`,并输出结构化报告。 - 如果你有历史基线目录,可以通过 `--baseline-root` 让工具直接做文本对比。 +- 更完整的协议级行为结构说明见 [DeepSeekSSE行为结构说明-2026-04-05.md](./DeepSeekSSE行为结构说明-2026-04-05.md)。 ### 对单个样本做回放比对 diff --git a/tests/raw_stream_samples/README.md b/tests/raw_stream_samples/README.md index 491de6d..1942316 100644 --- a/tests/raw_stream_samples/README.md +++ b/tests/raw_stream_samples/README.md @@ -10,6 +10,7 @@ - `content-filter-trigger-20260405-jwt3`:真实命中的 `CONTENT_FILTER` 风控流,用于验证终态处理与拒答格式。 默认回放工具会优先读取 [`manifest.json`](./manifest.json) 中的 `default_samples`,以稳定固定回放集。 +更完整的协议级行为结构说明见 [docs/DeepSeekSSE行为结构说明-2026-04-05.md](../../docs/DeepSeekSSE行为结构说明-2026-04-05.md)。 ## 自动采集接口