diff --git a/.gitignore b/.gitignore index c7c3919..2d70b75 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ yarn.lock pnpm-lock.yaml # Build artifacts +dist/ *.tsbuildinfo .cache/ .parcel-cache/ diff --git a/API.en.md b/API.en.md index 8112452..53ab8a6 100644 --- a/API.en.md +++ b/API.en.md @@ -165,6 +165,8 @@ Gemini-compatible clients can also send `x-goog-api-key`, `?key=`, or `?api_key= | PUT | `/admin/chat-history/settings` | Admin | Update conversation history retention limit | | GET | `/admin/version` | Admin | Check current version and latest Release | +OpenAI `/v1/*` paths are canonical. For clients configured with the bare DS2API service URL, the same OpenAI handlers are also exposed through root shortcuts: `/models`, `/models/{id}`, `/chat/completions`, `/responses`, `/responses/{response_id}`, `/embeddings`, and `/files`. + --- ## Health Endpoints diff --git a/API.md b/API.md index f47f8f9..7c7bd9b 100644 --- a/API.md +++ b/API.md @@ -165,6 +165,8 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=` | PUT | `/admin/chat-history/settings` | Admin | 更新对话记录保留条数 | | GET | `/admin/version` | Admin | 查询当前版本与最新 Release | +OpenAI `/v1/*` 仍是规范路径。对于只配置 DS2API 根地址的客户端,同一套 OpenAI handler 也通过根路径快捷路由暴露:`/models`、`/models/{id}`、`/chat/completions`、`/responses`、`/responses/{response_id}`、`/embeddings`、`/files`。 + --- ## 健康检查 diff --git a/Dockerfile b/Dockerfile index d5113f6..62269ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ WORKDIR /app RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates \ && groupadd -r ds2api && useradd -r -g ds2api -d /app -s /sbin/nologin ds2api \ - && mkdir -p /app/data && chown -R ds2api:ds2api /app \ + && mkdir -p /app/data /data && chown -R ds2api:ds2api /app /data \ && rm -rf /var/lib/apt/lists/* COPY --from=busybox-tools /bin/busybox /usr/local/bin/busybox EXPOSE 5001 diff --git a/README.MD b/README.MD index b335043..2ca87a5 100644 --- a/README.MD +++ b/README.MD @@ -131,6 +131,8 @@ flowchart LR | WebUI 管理台 | `/admin` 单页应用(中英文双语、深色模式,支持查看服务器端对话记录) | | 运维探针 | `GET /healthz`(存活)、`GET /readyz`(就绪) | +OpenAI `/v1/*` 仍是推荐的规范路径;同时支持 `/models`、`/chat/completions`、`/responses`、`/embeddings`、`/files` 等根路径快捷路由,方便只配置 DS2API 根地址的第三方客户端。 + ## 平台兼容矩阵 | 级别 | 平台 | 当前状态 | @@ -245,6 +247,7 @@ docker-compose logs -f 默认 `docker-compose.yml` 会把宿主机 `6011` 映射到容器内的 `5001`。如果你希望直接对外暴露 `5001`,请设置 `DS2API_HOST_PORT=5001`(或者手动调整 `ports` 配置)。 同时默认把 `./config.json` 挂载到容器 `/data/config.json`,并设置 `DS2API_CONFIG_PATH=/data/config.json`,用于避免 `/app` 只读导致运行时 token 持久化失败。 +镜像会预创建 `/data` 并授权给非 root 的 `ds2api` 用户;如果使用单文件 bind mount,请确保宿主机 `config.json` 对容器用户可读写,例如 `chmod 644 config.json`。 更新镜像:`docker-compose up -d --build` diff --git a/README.en.md b/README.en.md index 1376767..e11c396 100644 --- a/README.en.md +++ b/README.en.md @@ -128,6 +128,8 @@ For the full module-by-module architecture and directory responsibilities, see [ | WebUI Admin Panel | SPA at `/admin` (bilingual Chinese/English, dark mode, with server-side conversation history) | | Health Probes | `GET /healthz` (liveness), `GET /readyz` (readiness) | +OpenAI `/v1/*` routes remain canonical, and DS2API also accepts root shortcuts such as `/models`, `/chat/completions`, `/responses`, `/embeddings`, and `/files` for clients configured with the bare service URL. + ## Platform Compatibility Matrix | Tier | Platform | Status | diff --git a/VERSION b/VERSION index 6aba2b2..fae6e3d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.2.0 +4.2.1 diff --git a/docs/DEPLOY.en.md b/docs/DEPLOY.en.md index d082326..bbaed88 100644 --- a/docs/DEPLOY.en.md +++ b/docs/DEPLOY.en.md @@ -131,6 +131,7 @@ docker-compose logs -f The default `docker-compose.yml` directly uses `ghcr.io/cjackhwang/ds2api:latest` and maps host port `6011` to container port `5001`. If you want `5001` exposed directly, set `DS2API_HOST_PORT=5001` (or adjust the `ports` mapping). The compose template also defaults to `DS2API_CONFIG_PATH=/data/config.json` with `./config.json:/data/config.json` mounted, so deployments avoid read-only `/app` persistence issues by default. +The image pre-creates `/data` and grants it to the non-root `ds2api` user. If you bind-mount a single host file, make sure `config.json` is readable/writable by the container user, for example with `chmod 644 config.json`; otherwise Linux UID/GID mismatches can still cause `open /data/config.json: permission denied`. Compatibility note: when `DS2API_CONFIG_PATH` is unset and runtime base dir is `/app`, newer versions prefer `/data/config.json`; if that file is missing but legacy `/app/config.json` exists, DS2API automatically falls back to the legacy path to avoid post-upgrade config loss. If you want a pinned version instead of `latest`, you can also pull a specific tag directly: diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 3faaf95..8c9cba7 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -131,6 +131,7 @@ docker-compose logs -f 默认 `docker-compose.yml` 直接使用 `ghcr.io/cjackhwang/ds2api:latest`,并把宿主机 `6011` 映射到容器内的 `5001`。如果你希望直接对外暴露 `5001`,请设置 `DS2API_HOST_PORT=5001`(或者手动调整 `ports` 配置)。 Compose 模板还会默认设置 `DS2API_CONFIG_PATH=/data/config.json` 并挂载 `./config.json:/data/config.json`,优先避免 `/app` 只读带来的配置持久化问题。 +镜像内会预创建 `/data` 并授权给非 root 的 `ds2api` 用户;如果你使用 bind mount 单文件,请确保宿主机 `config.json` 至少可被容器用户读取/写入,例如 `chmod 644 config.json`,否则 Linux UID/GID 不一致时仍可能出现 `open /data/config.json: permission denied`。 兼容说明:若未设置 `DS2API_CONFIG_PATH` 且运行目录是 `/app`,新版本会优先使用 `/data/config.json`;当该文件不存在但检测到历史 `/app/config.json` 时,会自动回退读取旧路径,避免升级后“配置丢失”。 如需固定版本,也可以直接拉取指定 tag: diff --git a/docs/DeepSeekSSE行为结构说明-2026-04-05.md b/docs/DeepSeekSSE行为结构说明-2026-04-05.md index a5c1fce..007f4f4 100644 --- a/docs/DeepSeekSSE行为结构说明-2026-04-05.md +++ b/docs/DeepSeekSSE行为结构说明-2026-04-05.md @@ -313,3 +313,14 @@ parse SSE block - 解析器应当对未知字段、未知路径、未知事件保持容忍。 如果你要把这份说明用于实际开发,建议同时保留原始流样本、回放脚本和回归测试,不要只依赖本文。 + +## 2026-04-29 最近线上样本增量观察 + +基于 `longtext-deepseek-v4-flash-20260429` 与 `longtext-deepseek-v4-pro-20260429` 两个真实账号长文本样本,近期格式变化要点如下: + +1. `data:` 事件中仍大量出现 `{"v":"..."}` 的无路径增量(`p` 缺失),解析器必须把空路径视为可见正文候选,而不能只依赖 `response/content`。 +2. 对象形态 `v`(如 `{"text":"..."}` / `{"content":"..."}`)仍会出现,且可能与无路径 chunk 混用;仅按字符串处理会导致正文丢块。 +3. 多轮 continuation 场景下,后续 chunk 可能不再重复显式 `status`,状态机需要保留上一轮 `INCOMPLETE` 语义直到出现终态。 +4. 2026-04-29 起客户端头部版本基线上调到 `x-client-version: 2.0.3`,否则部分账号会出现上游行为不一致(包括空输出与补轮异常)。 + +建议:新增样本默认回放应优先覆盖「长文本 + 多轮 + 无路径 chunk」组合,避免只用短样本导致回归漏检。 diff --git a/docs/prompt-compatibility.md b/docs/prompt-compatibility.md index 5bf6025..d92cea3 100644 --- a/docs/prompt-compatibility.md +++ b/docs/prompt-compatibility.md @@ -98,6 +98,7 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools` - `prompt` 才是对话上下文主载体。 - `ref_file_ids` 只承载文件引用,不承载普通文本消息。 - `tools` 不会作为“原生工具 schema”直接下发给下游,而是被改写进 `prompt`。 +- 对外返回给客户端的 `prompt_tokens` / `input_tokens` / `promptTokenCount` 不再按“最后一条消息”或字符粗估近似返回,而是基于**完整上下文 prompt**做 tokenizer 计数;为了避免上下文实际超限但客户端误以为还能塞下,请求侧上下文 token 会额外保守上浮一点,宁可略大也不低估。 - 当前 `/v1/chat/completions` 业务路径仍是“每次请求新建一个远端 `chat_session_id`,并默认发送 `parent_message_id: null`”;因此 DS2API 对外默认表现为“新会话 + prompt 拼历史”,而不是复用 DeepSeek 原生会话树。 - 但 DeepSeek 远端本身支持同一 `chat_session_id` 的跨轮次持续对话。2026-04-27 已用项目内现有 DeepSeek client 做过一次不改业务代码的双轮实测:同一 `chat_session_id` 下,第 1 轮返回 `request_message_id=1` / `response_message_id=2` / 文本 `SESSION_TEST_ONE`;第 2 轮重新获取一次 PoW,并发送 `parent_message_id=2` 后,成功返回 `request_message_id=3` / `response_message_id=4` / 文本 `SESSION_TEST_TWO`。这说明“同远端会话持续聊天”能力存在,且每轮需要携带正确的 parent/message 链接信息,同时重新获取对应轮次可用的 PoW。 - OpenAI Chat / Responses 原生走统一 OpenAI 标准化与 DeepSeek payload 组装;Claude / Gemini 会尽量复用 OpenAI prompt/tool 语义,其中 Gemini 直接复用 `promptcompat.BuildOpenAIPromptForAdapter`,Claude 消息接口在可代理场景会转换为 OpenAI chat 形态再执行。 @@ -155,10 +156,12 @@ OpenAI Chat / Responses 在标准化后、current input file 之前,会默认 工具调用正例现在优先示范官方 DSML 风格:`<|DSML|tool_calls>` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`。 兼容层仍接受旧式纯 `` wrapper,但提示词会优先要求模型输出官方 DSML 标签,并强调不能只输出 closing wrapper 而漏掉 opening tag。需要注意:这是“兼容 DSML 外壳,内部仍以 XML 解析语义为准”,不是原生 DSML 全链路实现;DSML 标签会在解析入口归一化回现有 XML 标签后继续走同一套 parser。 数组参数使用 `...` 子节点表示;当某个参数体只包含 item 子节点时,Go / Node 解析器会把它还原成数组,避免 `questions` / `options` 这类 schema 中要求 array 的参数被误解析成 `{ "item": ... }` 对象。若模型把完整结构化 XML fragment 误包进 CDATA,兼容层会在保护 `content` / `command` 等原文字段的前提下,尝试把非原文字段中的 CDATA XML fragment 还原成 object / array。不过,如果 CDATA 只是单个平面的 XML/HTML 标签,例如 `urgent` 这种行内标记,兼容层会保留原始字符串,不会强行升成 object / array;只有明显表示结构的 CDATA 片段,例如多兄弟节点、嵌套子节点或 `item` 列表,才会触发结构化恢复。 +Go 侧读取 DeepSeek SSE 时不再依赖 `bufio.Scanner` 的固定 2MiB 单行上限;当写文件类工具把很长的 `content` 放在单个 `data:` 行里返回时,非流式收集、流式解析和 auto-continue 透传都会保留完整行,再进入同一套工具解析与序列化流程。 在 assistant 最终回包阶段,如果某个 tool 参数在声明 schema 中明确是 `string`,兼容层会在把解析后的 `tool_calls` / `function_call` 重新序列化成 OpenAI / Responses / Claude 可见参数前,递归把该路径上的 number / bool / object / array 统一转成字符串;其中 object / array 会压成紧凑 JSON 字符串。这个保护只对 schema 明确声明为 string 的路径生效,不会改写本来就是 `number` / `boolean` / `object` / `array` 的参数。这样可以兼容 DeepSeek 输出了结构化片段、但上游客户端工具 schema 又严格要求字符串参数的场景(例如 `content`、`prompt`、`path`、`taskId` 等)。 工具 schema 的权威来源始终是**当前请求实际携带的 schema**,而不是同名工具在其他 runtime(Claude Code / OpenCode / Codex 等)里的默认印象。兼容层现在会同时兼容 OpenAI 风格 `function.parameters`、直接工具对象上的 `parameters` / `input_schema`、以及 camelCase 的 `inputSchema` / `schema`,并在最终输出阶段按这份请求内 schema 决定是保留 array/object,还是仅对明确声明为 `string` 的路径做字符串化。该规则同样适用于 Claude 的流式收尾和 Vercel Node 流式 tool-call formatter,避免不同 runtime 因 schema shape 差异而出现同名工具参数类型漂移。 正例中的工具名只会来自当前请求实际声明的工具;如果当前请求没有足够的已知工具形态,就省略对应的单工具、多工具或嵌套示例,避免把不可用工具名写进 prompt。 对执行类工具,脚本内容必须进入执行参数本身:`Bash` / `execute_command` 使用 `command`,`exec_command` 使用 `cmd`;不要把脚本示范成 `path` / `content` 文件写入参数。 +如果当前请求声明了 `Read` / `read_file` 这类读取工具,兼容层会额外注入一条 read-tool cache guard:当读取结果只表示“文件未变更 / 已在历史中 / 请引用先前上下文 / 没有正文内容”时,模型必须把它视为内容不可用,不能反复调用同一个无正文读取;应改为请求完整正文读取能力,或向用户说明需要重新提供文件内容。这个约束只缓解客户端缓存返回空内容导致的死循环,DS2API 不会也无法凭空恢复客户端本地文件正文。 OpenAI 路径实现: [internal/promptcompat/tool_prompt.go](../internal/promptcompat/tool_prompt.go) @@ -249,6 +252,7 @@ OpenAI 文件相关实现: - `current_input_file` 默认开启;它用于把“完整上下文”合并进 `history.txt` 上下文文件。当最新 user turn 的纯文本长度达到 `current_input_file.min_chars`(默认 `0`)时,兼容层会上传一个文件名为 `history.txt` 的上下文文件,并在 live prompt 中只保留一个中性的 user 消息要求模型直接回答最新请求,不再暴露文件名或要求模型读取本地文件。 - 如果 `current_input_file.enabled=false`,请求会直接透传,不上传任何拆分上下文文件。 - 旧的 `history_split.enabled` / `history_split.trigger_after_turns` 会被读取进配置对象以保持兼容,但不会触发拆分上传,也不会影响 `current_input_file` 的默认开启。 +- 即使触发 `current_input_file` 后 live prompt 被缩短,对客户端回包里的上下文 token 统计,仍会沿用**拆分前的完整 prompt 语义**做计数,而不是按缩短后的占位 prompt 计算;否则会把真实上下文显著算小。 相关实现: diff --git a/docs/toolcall-semantics.md b/docs/toolcall-semantics.md index 5a3480b..0f24d6f 100644 --- a/docs/toolcall-semantics.md +++ b/docs/toolcall-semantics.md @@ -26,7 +26,7 @@ ``` -这不是原生 DSML 全链路实现。DSML 只作为 prompt 外壳和解析入口别名;进入 parser 前会被归一化成 `` / `` / ``,内部仍以现有 XML 解析语义为准。 +这不是原生 DSML 全链路实现。DSML 主要用于让模型有意识地输出协议标识,隔离普通 XML 语义;进入 parser 前会按固定本地标签名归一化成 `` / `` / ``,内部仍以现有 XML 解析语义为准。 约束: @@ -39,7 +39,8 @@ 兼容修复: - 如果模型漏掉 opening wrapper,但后面仍输出了一个或多个 invoke 并以 closing wrapper 收尾,Go 解析链路会在解析前补回缺失的 opening wrapper。 -- 如果模型把 DSML 标签里的分隔符 `|` 写漏成空格(例如 `<|DSML tool_calls>` / `<|DSML invoke>` / `<|DSML parameter>`,或无 leading pipe 的 `` 形态),或把 `DSML` 与工具标签名直接黏连(例如 `` / `` / ``),或把最前面的 pipe 误写成全宽竖线(例如 `<|DSML|tool_calls>` / `<|DSML|invoke>` / `<|DSML|parameter>`),Go / Node 会在固定工具标签名范围内归一化;相似但非工具标签名(如 `tool_calls_extra`)仍按普通文本处理。 +- Go / Node 解析层不再枚举每一种 DSML typo。它会把工具标签名前的 `DSML`、管道符 `|` / `|`、空白、重复 leading `<` 视为可容忍的协议噪声,然后只匹配固定本地标签名 `tool_calls` / `invoke` / `parameter`。例如 ``、`<<|DSML|tool_calls>`、`<|DSML tool_calls>`、``、`<` 都会归一化;相似但非固定标签名(如 `tool_calls_extra`)仍按普通文本处理。 +- 如果模型在固定工具标签名后多输出一个尾部管道符,例如 `<|DSML|tool_calls|` / `<|DSML|invoke|` / `<|DSML|parameter|`,兼容层会把这个尾部 `|` 当作异常标签终止符并补齐缺失的 `>`;如果后面已经有 `>`,也会消费这个多余 `|` 后再归一化。 - 这是一个针对常见模型失误的窄修复,不改变推荐输出格式;prompt 仍要求模型直接输出完整 DSML 外壳。 - 裸 `` / `` 不会被当成“已支持的工具语法”;只有 `tool_calls` wrapper 或可修复的缺失 opening wrapper 才会进入工具调用路径。 @@ -53,7 +54,7 @@ 在流式链路中(Go / Node 一致): -- DSML `<|DSML|tool_calls>` wrapper、兼容变体(``、`<|tool_calls>`、`<|tool_calls>`、`<|DSML|tool_calls>`)、窄容错空格分隔形态(如 `<|DSML tool_calls>`)、黏连形态(如 ``)和 canonical `` wrapper 都会进入结构化捕获 +- DSML `<|DSML|tool_calls>` wrapper、基于固定本地标签名的 DSML 噪声容错形态、尾部管道符形态(如 `<|DSML|tool_calls|`)和 canonical `` wrapper 都会进入结构化捕获 - 如果流里直接从 invoke 开始,但后面补上了 closing wrapper,Go 流式筛分也会按缺失 opening wrapper 的修复路径尝试恢复 - 已识别成功的工具调用不会再次回流到普通文本 - 不符合新格式的块不会执行,并继续按原样文本透传 @@ -61,6 +62,7 @@ - 支持嵌套围栏(如 4 反引号嵌套 3 反引号)和 CDATA 内围栏保护 - 如果模型把 `` 或 Markdown inline code 里的 `<|DSML|tool_calls>`)而后面紧跟真正工具调用时,sieve 会跳过不可解析的 mention 候选并继续匹配后续真实工具块,不会因 mention 导致工具调用丢失,也不会截断 mention 后的正文 +- Go 侧 SSE 读取不再使用 `bufio.Scanner` 的固定 token 上限;单个 `data:` 行中包含很长的写文件参数时,非流式收集、流式解析与 auto-continue 透传都应保留完整行,再交给 tool parser 处理 另外,`` 的值如果本身是合法 JSON 字面量,也会按结构化值解析,而不是一律保留为字符串。例如 `123`、`true`、`null`、`[1,2]`、`{"a":1}` 都会还原成对应的 number / boolean / null / array / object。 结构化 XML 参数也会还原为 JSON 结构:如果参数体只包含一个或多个 `...` 子节点,会输出数组;嵌套对象里的 item-only 字段也同样按数组处理。例如 `...` 会输出 `{"questions":[{"question":"..."}]}`,而不是 `{"questions":{"item":...}}`。 @@ -94,7 +96,7 @@ node --test tests/node/stream-tool-sieve.test.js - DSML `<|DSML|tool_calls>` wrapper 正常解析 - legacy canonical `` wrapper 正常解析 -- 别名变体(``、`<|tool_calls>`、`<|tool_calls>`)、DSML 空格分隔 typo(如 `<|DSML tool_calls>`)和黏连 typo(如 ``)正常解析 +- 固定本地标签名的 DSML 噪声容错形态(如 ``、`<<|DSML|tool_calls>`、`<|DSML tool_calls>`、``、`<`)正常解析 - 混搭标签(DSML wrapper + canonical inner)归一化后正常解析 - 波浪线围栏 `~~~` 内的示例不执行 - 嵌套围栏(4 反引号嵌套 3 反引号)内的示例不执行 diff --git a/go.mod b/go.mod index 2613f89..9ce4793 100644 --- a/go.mod +++ b/go.mod @@ -6,10 +6,13 @@ require ( github.com/andybalholm/brotli v1.2.1 github.com/go-chi/chi/v5 v5.2.5 github.com/google/uuid v1.6.0 + github.com/hupe1980/go-tiktoken v0.0.10 github.com/refraction-networking/utls v1.8.2 github.com/router-for-me/CLIProxyAPI/v6 v6.9.14 ) +require github.com/dlclark/regexp2 v1.11.5 // indirect + require ( github.com/klauspost/compress v1.18.5 // indirect github.com/sirupsen/logrus v1.9.4 // indirect diff --git a/go.sum b/go.sum index 4b47fb0..2936ba8 100644 --- a/go.sum +++ b/go.sum @@ -2,10 +2,14 @@ github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eT github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hupe1980/go-tiktoken v0.0.10 h1:m6phOJaGyctqWdGIgwn9X8AfJvaG74tnQoDL+ntOUEQ= +github.com/hupe1980/go-tiktoken v0.0.10/go.mod h1:NME6d8hrE+Jo+kLUZHhXShYV8e40hYkm4BbSLQKtvAo= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -37,6 +41,8 @@ golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/chathistory/store.go b/internal/chathistory/store.go index 857b8bc..823cc24 100644 --- a/internal/chathistory/store.go +++ b/internal/chathistory/store.go @@ -310,8 +310,12 @@ func (s *Store) Update(id string, params UpdateParams) (Entry, error) { if params.Status != "" { item.Status = params.Status } - item.ReasoningContent = params.ReasoningContent - item.Content = params.Content + if params.ReasoningContent != "" || item.ReasoningContent == "" { + item.ReasoningContent = params.ReasoningContent + } + if params.Content != "" || item.Content == "" { + item.Content = params.Content + } item.Error = strings.TrimSpace(params.Error) item.StatusCode = params.StatusCode item.ElapsedMs = params.ElapsedMs diff --git a/internal/chathistory/store_test.go b/internal/chathistory/store_test.go index d6209ef..79483a9 100644 --- a/internal/chathistory/store_test.go +++ b/internal/chathistory/store_test.go @@ -493,3 +493,112 @@ func TestStoreWritesOnlyChangedDetailFiles(t *testing.T) { t.Fatalf("expected untouched detail file to remain byte-identical") } } + +func TestUpdatePreservesContentWhenNewContentIsEmpty(t *testing.T) { + path := filepath.Join(t.TempDir(), "chat_history.json") + store := New(path) + + started, err := store.Start(StartParams{ + CallerID: "caller:abc", + Model: "deepseek-v4-flash", + Stream: true, + UserInput: "hello", + }) + if err != nil { + t.Fatalf("start entry failed: %v", err) + } + + if _, err := store.Update(started.ID, UpdateParams{ + Status: "streaming", + ReasoningContent: "let me think", + Content: "I'll help you with that.", + }); err != nil { + t.Fatalf("progress update failed: %v", err) + } + + updated, err := store.Update(started.ID, UpdateParams{ + Status: "success", + Content: "", + Completed: true, + }) + if err != nil { + t.Fatalf("success update failed: %v", err) + } + + if updated.Content != "I'll help you with that." { + t.Fatalf("expected content to be preserved, got %q", updated.Content) + } + if updated.ReasoningContent != "let me think" { + t.Fatalf("expected reasoning content to be preserved, got %q", updated.ReasoningContent) + } + + full, err := store.Get(started.ID) + if err != nil { + t.Fatalf("get entry failed: %v", err) + } + if full.Content != "I'll help you with that." { + t.Fatalf("expected persisted content to be preserved, got %q", full.Content) + } + if full.ReasoningContent != "let me think" { + t.Fatalf("expected persisted reasoning content to be preserved, got %q", full.ReasoningContent) + } +} + +func TestUpdateAllowsSettingContentFromEmpty(t *testing.T) { + path := filepath.Join(t.TempDir(), "chat_history.json") + store := New(path) + + started, err := store.Start(StartParams{ + CallerID: "caller:abc", + Model: "deepseek-v4-flash", + Stream: true, + UserInput: "hello", + }) + if err != nil { + t.Fatalf("start entry failed: %v", err) + } + + updated, err := store.Update(started.ID, UpdateParams{ + Status: "success", + Content: "final answer", + }) + if err != nil { + t.Fatalf("update failed: %v", err) + } + if updated.Content != "final answer" { + t.Fatalf("expected content to be set, got %q", updated.Content) + } +} + +func TestUpdateAllowsOverwritingContentWithNewValue(t *testing.T) { + path := filepath.Join(t.TempDir(), "chat_history.json") + store := New(path) + + started, err := store.Start(StartParams{ + CallerID: "caller:abc", + Model: "deepseek-v4-flash", + Stream: true, + UserInput: "hello", + }) + if err != nil { + t.Fatalf("start entry failed: %v", err) + } + + if _, err := store.Update(started.ID, UpdateParams{ + Status: "streaming", + Content: "partial", + }); err != nil { + t.Fatalf("first update failed: %v", err) + } + + updated, err := store.Update(started.ID, UpdateParams{ + Status: "success", + Content: "final answer", + }) + if err != nil { + t.Fatalf("second update failed: %v", err) + } + if updated.Content != "final answer" { + t.Fatalf("expected content to be overwritten, got %q", updated.Content) + } +} diff --git a/internal/deepseek/client/client_continue.go b/internal/deepseek/client/client_continue.go index b76d921..009c027 100644 --- a/internal/deepseek/client/client_continue.go +++ b/internal/deepseek/client/client_continue.go @@ -133,33 +133,51 @@ func pumpAutoContinue(ctx context.Context, pw *io.PipeWriter, initial io.ReadClo // sentinels are consumed (not forwarded) so that the downstream only sees // one final [DONE] at the very end. func streamBodyWithContinueState(ctx context.Context, pw *io.PipeWriter, body io.Reader, state *continueState) (bool, error) { - scanner := bufio.NewScanner(body) - scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024) + reader := bufio.NewReaderSize(body, 64*1024) hadDone := false - for scanner.Scan() { + for { select { case <-ctx.Done(): return hadDone, ctx.Err() default: } - line := append([]byte{}, scanner.Bytes()...) - trimmed := strings.TrimSpace(string(line)) - if trimmed == "" { - continue - } - if strings.HasPrefix(trimmed, "data:") { - data := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) - if data == "[DONE]" { - hadDone = true - continue + line, err := reader.ReadBytes('\n') + if len(line) == 0 && err != nil { + if err == io.EOF { + return hadDone, nil } - state.observe(data) + return hadDone, err } - if _, err := io.Copy(pw, bytes.NewReader(append(line, '\n'))); err != nil { + trimmed := strings.TrimSpace(string(line)) + if trimmed != "" { + if strings.HasPrefix(trimmed, "data:") { + data := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) + if data == "[DONE]" { + hadDone = true + if err != nil && err != io.EOF { + return hadDone, err + } + if err == io.EOF { + return hadDone, nil + } + continue + } + state.observe(data) + } + if !strings.HasSuffix(string(line), "\n") { + line = append(line, '\n') + } + if _, copyErr := io.Copy(pw, bytes.NewReader(line)); copyErr != nil { + return hadDone, copyErr + } + } + if err != nil { + if err == io.EOF { + return hadDone, nil + } return hadDone, err } } - return hadDone, scanner.Err() } // observe extracts continue-relevant signals from an SSE JSON chunk. @@ -175,34 +193,48 @@ func (s *continueState) observe(data string) { if id := intFrom(chunk["response_message_id"]); id > 0 { s.responseMessageID = id } - // Path-based status: {"p": "response/status", "v": "FINISHED"} - if p, _ := chunk["p"].(string); p == "response/status" { - s.setStatus(asString(chunk["v"])) - } + s.observeDirectPatch(asString(chunk["p"]), chunk["v"]) if p, _ := chunk["p"].(string); p == "response" { s.observeBatchPatches("response", chunk["v"]) } else { s.observeBatchPatches("", chunk["v"]) } - // Nested v.response - v, _ := chunk["v"].(map[string]any) - if response, _ := v["response"].(map[string]any); response != nil { - if id := intFrom(response["message_id"]); id > 0 { - s.responseMessageID = id - } - s.setStatus(asString(response["status"])) - if autoContinue, ok := response["auto_continue"].(bool); ok && autoContinue { + if v, _ := chunk["v"].(map[string]any); v != nil { + s.observeResponseObject(v["response"]) + } + if message, _ := chunk["message"].(map[string]any); message != nil { + s.observeResponseObject(message["response"]) + } +} + +func (s *continueState) observeDirectPatch(path string, value any) { + if s == nil { + return + } + switch strings.Trim(strings.TrimSpace(path), "/") { + case "response/status", "status", "response/quasi_status", "quasi_status": + s.setStatus(asString(value)) + case "response/auto_continue", "auto_continue": + if v, ok := value.(bool); ok && v { s.lastStatus = "AUTO_CONTINUE" } } - // Nested message.response - if message, _ := chunk["message"].(map[string]any); message != nil { - if response, _ := message["response"].(map[string]any); response != nil { - if id := intFrom(response["message_id"]); id > 0 { - s.responseMessageID = id - } - s.setStatus(asString(response["status"])) - } +} + +func (s *continueState) observeResponseObject(raw any) { + if s == nil { + return + } + response, _ := raw.(map[string]any) + if response == nil { + return + } + if id := intFrom(response["message_id"]); id > 0 { + s.responseMessageID = id + } + s.setStatus(asString(response["status"])) + if autoContinue, ok := response["auto_continue"].(bool); ok && autoContinue { + s.lastStatus = "AUTO_CONTINUE" } } @@ -230,6 +262,10 @@ func (s *continueState) observeBatchPatches(parentPath string, raw any) { switch strings.Trim(strings.TrimSpace(fullPath), "/") { case "response/status", "status", "response/quasi_status", "quasi_status": s.setStatus(asString(m["v"])) + case "response/auto_continue", "auto_continue": + if v, ok := m["v"].(bool); ok && v { + s.lastStatus = "AUTO_CONTINUE" + } } } } diff --git a/internal/deepseek/client/client_continue_test.go b/internal/deepseek/client/client_continue_test.go index 234d2af..b79dbca 100644 --- a/internal/deepseek/client/client_continue_test.go +++ b/internal/deepseek/client/client_continue_test.go @@ -150,6 +150,62 @@ func TestAutoContinueDoesNotTriggerOnPlainWIPWithoutExplicitContinuationSignal(t } } +func TestAutoContinuePassesThroughLongSingleSSELine(t *testing.T) { + payload := strings.Repeat("x", 2*1024*1024+4096) + initialBody := `data: {"p":"response/content","v":"` + payload + `"}` + "\n" + + `data: [DONE]` + "\n" + + body := newAutoContinueBody(context.Background(), io.NopCloser(strings.NewReader(initialBody)), "session-123", 8, func(context.Context, string, int) (*http.Response, error) { + return nil, errors.New("continue should not have been called") + }) + defer func() { _ = body.Close() }() + + out, err := io.ReadAll(body) + if err != nil { + t.Fatalf("read body failed: %v", err) + } + if !bytes.Contains(out, []byte(payload)) { + t.Fatalf("expected long SSE payload to pass through, got len=%d want payload len=%d", len(out), len(payload)) + } + if !bytes.Contains(out, []byte(`data: [DONE]`)) { + t.Fatalf("expected final DONE sentinel in body, got len=%d", len(out)) + } +} + +func TestAutoContinueTriggersOnDirectQuasiStatusIncomplete(t *testing.T) { + initialBody := strings.Join([]string{ + `data: {"response_message_id":321,"p":"response/content","v":""}` + "\n" + + `data: {"p":"response/status","v":"FINISHED"}` + "\n" + + `data: [DONE]` + "\n", + )), + }, nil + }) + defer func() { _ = body.Close() }() + + out, err := io.ReadAll(body) + if err != nil { + t.Fatalf("read body failed: %v", err) + } + if continueCalls.Load() != 1 { + t.Fatalf("expected exactly one continue call, got %d", continueCalls.Load()) + } + if !bytes.Contains(out, []byte("part-one")) || !bytes.Contains(out, []byte("-part-two")) { + t.Fatalf("expected continued tool content in body, got=%s", string(out)) + } +} + func TestAutoContinueTriggersOnResponseBatchQuasiStatusIncomplete(t *testing.T) { initialBody := strings.Join([]string{ `data: {"response_message_id":321,"v":{"response":{"message_id":321,"status":"WIP","auto_continue":false}}}`, @@ -219,3 +275,33 @@ func (d failingOrCompletionDoer) Do(req *http.Request) (*http.Response, error) { } return nil, errors.New("forced stream failure") } + +func TestAutoContinuePreservesIncompleteStateWhenNextChunkOmitsStatus(t *testing.T) { + initialBody := strings.Join([]string{ + `data: {"response_message_id":321,"v":{"response":{"message_id":321,"status":"INCOMPLETE"}}}`, + `data: {"p":"response/content","v":{"text":"continued"}}`, + `data: [DONE]`, + }, "\n") + "\n" + + var continueCalls atomic.Int32 + body := newAutoContinueBody(context.Background(), io.NopCloser(strings.NewReader(initialBody)), "session-123", 8, func(context.Context, string, int) (*http.Response, error) { + continueCalls.Add(1) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + `data: {"response_message_id":322,"p":"response/status","v":"FINISHED"}` + "\n" + + `data: [DONE]` + "\n", + )), + }, nil + }) + defer func() { _ = body.Close() }() + + _, err := io.ReadAll(body) + if err != nil { + t.Fatalf("read body failed: %v", err) + } + if continueCalls.Load() != 1 { + t.Fatalf("expected exactly one continue call, got %d", continueCalls.Load()) + } +} diff --git a/internal/deepseek/protocol/constants_shared.json b/internal/deepseek/protocol/constants_shared.json index 353f03d..08666e7 100644 --- a/internal/deepseek/protocol/constants_shared.json +++ b/internal/deepseek/protocol/constants_shared.json @@ -2,7 +2,7 @@ "client": { "name": "DeepSeek", "platform": "android", - "version": "2.0.1", + "version": "2.0.4", "android_api_level": "35", "locale": "zh_CN" }, @@ -24,4 +24,4 @@ "skip_exact_paths": [ "response/search_status" ] -} +} \ No newline at end of file diff --git a/internal/deepseek/protocol/sse.go b/internal/deepseek/protocol/sse.go index c11b72b..af942aa 100644 --- a/internal/deepseek/protocol/sse.go +++ b/internal/deepseek/protocol/sse.go @@ -2,20 +2,24 @@ package protocol import ( "bufio" + "io" "net/http" ) func ScanSSELines(resp *http.Response, onLine func([]byte) bool) error { - scanner := bufio.NewScanner(resp.Body) - buf := make([]byte, 0, 64*1024) - scanner.Buffer(buf, 2*1024*1024) - for scanner.Scan() { - if !onLine(scanner.Bytes()) { - break + reader := bufio.NewReaderSize(resp.Body, 64*1024) + for { + line, err := reader.ReadBytes('\n') + if len(line) > 0 { + if !onLine(line) { + return nil + } + } + if err != nil { + if err == io.EOF { + return nil + } + return err } } - if err := scanner.Err(); err != nil { - return err - } - return nil } diff --git a/internal/deepseek/protocol/sse_test.go b/internal/deepseek/protocol/sse_test.go new file mode 100644 index 0000000..17589c7 --- /dev/null +++ b/internal/deepseek/protocol/sse_test.go @@ -0,0 +1,26 @@ +package protocol + +import ( + "io" + "net/http" + "strings" + "testing" +) + +func TestScanSSELinesHandlesLongSingleLine(t *testing.T) { + payload := strings.Repeat("x", 2*1024*1024+4096) + body := "data: {\"p\":\"response/content\",\"v\":\"" + payload + "\"}\n" + resp := &http.Response{Body: io.NopCloser(strings.NewReader(body))} + + var got string + err := ScanSSELines(resp, func(line []byte) bool { + got = string(line) + return true + }) + if err != nil { + t.Fatalf("ScanSSELines returned error: %v", err) + } + if !strings.Contains(got, payload) { + t.Fatalf("long SSE line was not preserved: got len=%d want payload len=%d", len(got), len(payload)) + } +} diff --git a/internal/format/claude/render.go b/internal/format/claude/render.go index 4f9ada5..694f5fd 100644 --- a/internal/format/claude/render.go +++ b/internal/format/claude/render.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "ds2api/internal/prompt" "ds2api/internal/util" ) @@ -43,8 +44,23 @@ func BuildMessageResponse(messageID, model string, normalizedMessages []any, fin "stop_reason": stopReason, "stop_sequence": nil, "usage": map[string]any{ - "input_tokens": util.EstimateTokens(fmt.Sprintf("%v", normalizedMessages)), - "output_tokens": util.EstimateTokens(finalThinking) + util.EstimateTokens(finalText), + "input_tokens": util.CountPromptTokens(prompt.MessagesPrepareWithThinking(claudeMessageMaps(normalizedMessages), false), model), + "output_tokens": util.CountOutputTokens(finalThinking, model) + util.CountOutputTokens(finalText, model), }, } } + +func claudeMessageMaps(messages []any) []map[string]any { + if len(messages) == 0 { + return nil + } + out := make([]map[string]any, 0, len(messages)) + for _, item := range messages { + msg, ok := item.(map[string]any) + if !ok { + continue + } + out = append(out, msg) + } + return out +} diff --git a/internal/format/openai/render_chat.go b/internal/format/openai/render_chat.go index f88ba41..3fa6e53 100644 --- a/internal/format/openai/render_chat.go +++ b/internal/format/openai/render_chat.go @@ -29,7 +29,7 @@ func BuildChatCompletionWithToolCalls(completionID, model, finalPrompt, finalThi "created": time.Now().Unix(), "model": model, "choices": []map[string]any{{"index": 0, "message": messageObj, "finish_reason": finishReason}}, - "usage": BuildChatUsage(finalPrompt, finalThinking, finalText), + "usage": BuildChatUsageForModel(model, finalPrompt, finalThinking, finalText, 0), } } diff --git a/internal/format/openai/render_responses.go b/internal/format/openai/render_responses.go index 6148fdc..0c7dfbe 100644 --- a/internal/format/openai/render_responses.go +++ b/internal/format/openai/render_responses.go @@ -70,7 +70,7 @@ func BuildResponseObjectFromItems(responseID, model, finalPrompt, finalThinking, "model": model, "output": output, "output_text": outputText, - "usage": BuildResponsesUsage(finalPrompt, finalThinking, finalText), + "usage": BuildResponsesUsageForModel(model, finalPrompt, finalThinking, finalText, 0), } } diff --git a/internal/format/openai/render_test.go b/internal/format/openai/render_test.go index c1dc540..e174bd6 100644 --- a/internal/format/openai/render_test.go +++ b/internal/format/openai/render_test.go @@ -6,6 +6,7 @@ import ( "testing" "ds2api/internal/toolcall" + "ds2api/internal/util" ) func TestBuildResponseObjectKeepsFencedToolPayloadAsText(t *testing.T) { @@ -177,3 +178,17 @@ func TestBuildResponseObjectWithToolCallsCoercesSchemaDeclaredStringArguments(t t.Fatalf("expected response content stringified by schema, got %#v", args["content"]) } } + +func TestBuildChatUsageForModelUsesConservativePromptCount(t *testing.T) { + prompt := strings.Repeat("上下文token ", 40) + usage := BuildChatUsageForModel("deepseek-v4-flash", prompt, "", "ok", 0) + promptTokens, _ := usage["prompt_tokens"].(int) + if promptTokens <= util.EstimateTokens(prompt) { + t.Fatalf("expected conservative prompt token count > rough estimate, got=%d estimate=%d", promptTokens, util.EstimateTokens(prompt)) + } + totalTokens, _ := usage["total_tokens"].(int) + completionTokens, _ := usage["completion_tokens"].(int) + if totalTokens != promptTokens+completionTokens { + t.Fatalf("expected total tokens to add up, got usage=%#v", usage) + } +} diff --git a/internal/format/openai/render_usage.go b/internal/format/openai/render_usage.go index b328d20..08541a4 100644 --- a/internal/format/openai/render_usage.go +++ b/internal/format/openai/render_usage.go @@ -2,10 +2,10 @@ package openai import "ds2api/internal/util" -func BuildChatUsage(finalPrompt, finalThinking, finalText string) map[string]any { - promptTokens := util.EstimateTokens(finalPrompt) - reasoningTokens := util.EstimateTokens(finalThinking) - completionTokens := util.EstimateTokens(finalText) +func BuildChatUsageForModel(model, finalPrompt, finalThinking, finalText string, refFileTokens int) map[string]any { + promptTokens := util.CountPromptTokens(finalPrompt, model) + refFileTokens + reasoningTokens := util.CountOutputTokens(finalThinking, model) + completionTokens := util.CountOutputTokens(finalText, model) return map[string]any{ "prompt_tokens": promptTokens, "completion_tokens": reasoningTokens + completionTokens, @@ -16,13 +16,21 @@ func BuildChatUsage(finalPrompt, finalThinking, finalText string) map[string]any } } -func BuildResponsesUsage(finalPrompt, finalThinking, finalText string) map[string]any { - promptTokens := util.EstimateTokens(finalPrompt) - reasoningTokens := util.EstimateTokens(finalThinking) - completionTokens := util.EstimateTokens(finalText) +func BuildChatUsage(finalPrompt, finalThinking, finalText string) map[string]any { + return BuildChatUsageForModel("", finalPrompt, finalThinking, finalText, 0) +} + +func BuildResponsesUsageForModel(model, finalPrompt, finalThinking, finalText string, refFileTokens int) map[string]any { + promptTokens := util.CountPromptTokens(finalPrompt, model) + refFileTokens + reasoningTokens := util.CountOutputTokens(finalThinking, model) + completionTokens := util.CountOutputTokens(finalText, model) return map[string]any{ "input_tokens": promptTokens, "output_tokens": reasoningTokens + completionTokens, "total_tokens": promptTokens + reasoningTokens + completionTokens, } } + +func BuildResponsesUsage(finalPrompt, finalThinking, finalText string) map[string]any { + return BuildResponsesUsageForModel("", finalPrompt, finalThinking, finalText, 0) +} diff --git a/internal/httpapi/claude/handler_messages.go b/internal/httpapi/claude/handler_messages.go index de47d28..e7ed4cd 100644 --- a/internal/httpapi/claude/handler_messages.go +++ b/internal/httpapi/claude/handler_messages.go @@ -206,6 +206,7 @@ func (h *Handler) handleClaudeStreamRealtime(w http.ResponseWriter, r *http.Requ h.compatStripReferenceMarkers(), toolNames, toolsRaw, + buildClaudePromptTokenText(messages, thinkingEnabled), ) streamRuntime.sendMessageStart() diff --git a/internal/httpapi/claude/handler_tokens.go b/internal/httpapi/claude/handler_tokens.go index a369345..d122b0f 100644 --- a/internal/httpapi/claude/handler_tokens.go +++ b/internal/httpapi/claude/handler_tokens.go @@ -3,8 +3,6 @@ package claude import ( "encoding/json" "net/http" - - "ds2api/internal/util" ) func (h *Handler) CountTokens(w http.ResponseWriter, r *http.Request) { @@ -26,26 +24,11 @@ func (h *Handler) CountTokens(w http.ResponseWriter, r *http.Request) { writeClaudeError(w, http.StatusBadRequest, "Request must include 'model' and 'messages'.") return } - inputTokens := 0 - if sys, ok := req["system"].(string); ok { - inputTokens += util.EstimateTokens(sys) - } - for _, item := range messages { - msg, ok := item.(map[string]any) - if !ok { - continue - } - inputTokens += 2 - inputTokens += util.EstimateTokens(extractMessageContent(msg["content"])) - } - if tools, ok := req["tools"].([]any); ok { - for _, t := range tools { - b, _ := json.Marshal(t) - inputTokens += util.EstimateTokens(string(b)) - } - } - if inputTokens < 1 { - inputTokens = 1 + normalized, err := normalizeClaudeRequest(h.Store, req) + if err != nil { + writeClaudeError(w, http.StatusBadRequest, err.Error()) + return } + inputTokens := countClaudeInputTokens(normalized.Standard) writeJSON(w, http.StatusOK, map[string]any{"input_tokens": inputTokens}) } diff --git a/internal/httpapi/claude/prompt_token_text.go b/internal/httpapi/claude/prompt_token_text.go new file mode 100644 index 0000000..f70641c --- /dev/null +++ b/internal/httpapi/claude/prompt_token_text.go @@ -0,0 +1,7 @@ +package claude + +import "ds2api/internal/prompt" + +func buildClaudePromptTokenText(messages []any, thinkingEnabled bool) string { + return prompt.MessagesPrepareWithThinking(toMessageMaps(messages), thinkingEnabled) +} diff --git a/internal/httpapi/claude/standard_request.go b/internal/httpapi/claude/standard_request.go index 3f10723..e9edb4c 100644 --- a/internal/httpapi/claude/standard_request.go +++ b/internal/httpapi/claude/standard_request.go @@ -48,17 +48,18 @@ func normalizeClaudeRequest(store ConfigReader, req map[string]any) (claudeNorma return claudeNormalizedRequest{ Standard: promptcompat.StandardRequest{ - Surface: "anthropic_messages", - RequestedModel: strings.TrimSpace(model), - ResolvedModel: dsModel, - ResponseModel: strings.TrimSpace(model), - Messages: payload["messages"].([]any), - ToolsRaw: toolsRequested, - FinalPrompt: finalPrompt, - ToolNames: toolNames, - Stream: util.ToBool(req["stream"]), - Thinking: thinkingEnabled, - Search: searchEnabled, + Surface: "anthropic_messages", + RequestedModel: strings.TrimSpace(model), + ResolvedModel: dsModel, + ResponseModel: strings.TrimSpace(model), + Messages: payload["messages"].([]any), + PromptTokenText: finalPrompt, + ToolsRaw: toolsRequested, + FinalPrompt: finalPrompt, + ToolNames: toolNames, + Stream: util.ToBool(req["stream"]), + Thinking: thinkingEnabled, + Search: searchEnabled, }, NormalizedMessages: normalizedMessages, }, nil diff --git a/internal/httpapi/claude/stream_runtime_core.go b/internal/httpapi/claude/stream_runtime_core.go index 49fde53..de969e7 100644 --- a/internal/httpapi/claude/stream_runtime_core.go +++ b/internal/httpapi/claude/stream_runtime_core.go @@ -15,10 +15,11 @@ type claudeStreamRuntime struct { rc *http.ResponseController canFlush bool - model string - toolNames []string - messages []any - toolsRaw any + model string + toolNames []string + messages []any + toolsRaw any + promptTokenText string thinkingEnabled bool searchEnabled bool @@ -49,6 +50,7 @@ func newClaudeStreamRuntime( stripReferenceMarkers bool, toolNames []string, toolsRaw any, + promptTokenText string, ) *claudeStreamRuntime { return &claudeStreamRuntime{ w: w, @@ -62,6 +64,7 @@ func newClaudeStreamRuntime( stripReferenceMarkers: stripReferenceMarkers, toolNames: toolNames, toolsRaw: toolsRaw, + promptTokenText: promptTokenText, messageID: fmt.Sprintf("msg_%d", time.Now().UnixNano()), thinkingBlockIndex: -1, textBlockIndex: -1, diff --git a/internal/httpapi/claude/stream_runtime_emit.go b/internal/httpapi/claude/stream_runtime_emit.go index c2fba19..e071cdc 100644 --- a/internal/httpapi/claude/stream_runtime_emit.go +++ b/internal/httpapi/claude/stream_runtime_emit.go @@ -42,7 +42,10 @@ func (s *claudeStreamRuntime) sendPing() { } func (s *claudeStreamRuntime) sendMessageStart() { - inputTokens := util.EstimateTokens(fmt.Sprintf("%v", s.messages)) + inputTokens := countClaudeInputTokensFromText(s.promptTokenText, s.model) + if inputTokens == 0 { + inputTokens = util.CountPromptTokens(fmt.Sprintf("%v", s.messages), s.model) + } s.send("message_start", map[string]any{ "type": "message_start", "message": map[string]any{ diff --git a/internal/httpapi/claude/stream_runtime_finalize.go b/internal/httpapi/claude/stream_runtime_finalize.go index 32e9b5f..9c239f1 100644 --- a/internal/httpapi/claude/stream_runtime_finalize.go +++ b/internal/httpapi/claude/stream_runtime_finalize.go @@ -109,7 +109,7 @@ func (s *claudeStreamRuntime) finalize(stopReason string) { } } - outputTokens := util.EstimateTokens(finalThinking) + util.EstimateTokens(finalText) + outputTokens := util.CountOutputTokens(finalThinking, s.model) + util.CountOutputTokens(finalText, s.model) s.send("message_delta", map[string]any{ "type": "message_delta", "delta": map[string]any{ diff --git a/internal/httpapi/claude/token_count.go b/internal/httpapi/claude/token_count.go new file mode 100644 index 0000000..2a06537 --- /dev/null +++ b/internal/httpapi/claude/token_count.go @@ -0,0 +1,20 @@ +package claude + +import ( + "strings" + + "ds2api/internal/promptcompat" + "ds2api/internal/util" +) + +func countClaudeInputTokens(stdReq promptcompat.StandardRequest) int { + promptText := stdReq.PromptTokenText + if strings.TrimSpace(promptText) == "" { + promptText = stdReq.FinalPrompt + } + return countClaudeInputTokensFromText(promptText, stdReq.ResolvedModel) +} + +func countClaudeInputTokensFromText(promptText, model string) int { + return util.CountPromptTokens(promptText, model) +} diff --git a/internal/httpapi/gemini/convert_request.go b/internal/httpapi/gemini/convert_request.go index ca1497a..43697e7 100644 --- a/internal/httpapi/gemini/convert_request.go +++ b/internal/httpapi/gemini/convert_request.go @@ -36,16 +36,17 @@ func normalizeGeminiRequest(store ConfigReader, routeModel string, req map[strin passThrough := collectGeminiPassThrough(req) return promptcompat.StandardRequest{ - Surface: "google_gemini", - RequestedModel: requestedModel, - ResolvedModel: resolvedModel, - ResponseModel: requestedModel, - Messages: messagesRaw, - FinalPrompt: finalPrompt, - ToolNames: toolNames, - Stream: stream, - Thinking: thinkingEnabled, - Search: searchEnabled, - PassThrough: passThrough, + Surface: "google_gemini", + RequestedModel: requestedModel, + ResolvedModel: resolvedModel, + ResponseModel: requestedModel, + Messages: messagesRaw, + PromptTokenText: finalPrompt, + FinalPrompt: finalPrompt, + ToolNames: toolNames, + Stream: stream, + Thinking: thinkingEnabled, + Search: searchEnabled, + PassThrough: passThrough, }, nil } diff --git a/internal/httpapi/gemini/handler_generate.go b/internal/httpapi/gemini/handler_generate.go index c6a08eb..00c4655 100644 --- a/internal/httpapi/gemini/handler_generate.go +++ b/internal/httpapi/gemini/handler_generate.go @@ -227,7 +227,7 @@ func (h *Handler) handleNonStreamGenerateContent(w http.ResponseWriter, resp *ht //nolint:unused // retained for native Gemini non-stream handling path. func buildGeminiGenerateContentResponse(model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any { parts := buildGeminiPartsFromFinal(finalText, finalThinking, toolNames) - usage := buildGeminiUsage(finalPrompt, finalThinking, finalText) + usage := buildGeminiUsage(model, finalPrompt, finalThinking, finalText) return map[string]any{ "candidates": []map[string]any{ { @@ -245,10 +245,10 @@ func buildGeminiGenerateContentResponse(model, finalPrompt, finalThinking, final } //nolint:unused // retained for native Gemini non-stream handling path. -func buildGeminiUsage(finalPrompt, finalThinking, finalText string) map[string]any { - promptTokens := util.EstimateTokens(finalPrompt) - reasoningTokens := util.EstimateTokens(finalThinking) - completionTokens := util.EstimateTokens(finalText) +func buildGeminiUsage(model, finalPrompt, finalThinking, finalText string) map[string]any { + promptTokens := util.CountPromptTokens(finalPrompt, model) + reasoningTokens := util.CountOutputTokens(finalThinking, model) + completionTokens := util.CountOutputTokens(finalText, model) return map[string]any{ "promptTokenCount": promptTokens, "candidatesTokenCount": reasoningTokens + completionTokens, diff --git a/internal/httpapi/gemini/handler_stream_runtime.go b/internal/httpapi/gemini/handler_stream_runtime.go index 13729fb..fb72981 100644 --- a/internal/httpapi/gemini/handler_stream_runtime.go +++ b/internal/httpapi/gemini/handler_stream_runtime.go @@ -194,6 +194,6 @@ func (s *geminiStreamRuntime) finalize() { }, }, "modelVersion": s.model, - "usageMetadata": buildGeminiUsage(s.finalPrompt, finalThinking, finalText), + "usageMetadata": buildGeminiUsage(s.model, s.finalPrompt, finalThinking, finalText), }) } diff --git a/internal/httpapi/openai/chat/chat_history_test.go b/internal/httpapi/openai/chat/chat_history_test.go index 1fd1b93..2201f0f 100644 --- a/internal/httpapi/openai/chat/chat_history_test.go +++ b/internal/httpapi/openai/chat/chat_history_test.go @@ -126,6 +126,7 @@ func TestStartChatHistoryRecoversFromTransientWriteFailure(t *testing.T) { session := startChatHistory(historyStore, req, a, stdReq) if session == nil { t.Fatalf("expected session even when initial persistence fails") + return } if session.disabled { t.Fatalf("expected session to remain active after transient start failure") @@ -194,7 +195,7 @@ func TestHandleStreamContextCancelledMarksHistoryStopped(t *testing.T) { rec := httptest.NewRecorder() resp := makeOpenAISSEHTTPResponse(`data: {"p":"response/content","v":"hello"}`, `data: [DONE]`) - h.handleStream(rec, req, resp, "cid-stop", "deepseek-v4-flash", "prompt", false, false, nil, nil, session) + h.handleStream(rec, req, resp, "cid-stop", "deepseek-v4-flash", "prompt", 0, false, false, nil, nil, session) snapshot, err := historyStore.Snapshot() if err != nil { diff --git a/internal/httpapi/openai/chat/chat_stream_runtime.go b/internal/httpapi/openai/chat/chat_stream_runtime.go index 21d1f4f..17ff0d5 100644 --- a/internal/httpapi/openai/chat/chat_stream_runtime.go +++ b/internal/httpapi/openai/chat/chat_stream_runtime.go @@ -16,12 +16,13 @@ type chatStreamRuntime struct { rc *http.ResponseController canFlush bool - completionID string - created int64 - model string - finalPrompt string - toolNames []string - toolsRaw any + completionID string + created int64 + model string + finalPrompt string + refFileTokens int + toolNames []string + toolsRaw any thinkingEnabled bool searchEnabled bool @@ -52,6 +53,32 @@ type chatStreamRuntime struct { finalErrorCode string } +type chatDeltaBatch struct { + runtime *chatStreamRuntime + field string + text strings.Builder +} + +func (b *chatDeltaBatch) append(field, text string) { + if text == "" { + return + } + if b.field != "" && b.field != field { + b.flush() + } + b.field = field + b.text.WriteString(text) +} + +func (b *chatDeltaBatch) flush() { + if b.field == "" || b.text.Len() == 0 { + return + } + b.runtime.sendDelta(map[string]any{b.field: b.text.String()}) + b.field = "" + b.text.Reset() +} + func newChatStreamRuntime( w http.ResponseWriter, rc *http.ResponseController, @@ -106,6 +133,23 @@ func (s *chatStreamRuntime) sendChunk(v any) { } } +func (s *chatStreamRuntime) sendDelta(delta map[string]any) { + if len(delta) == 0 { + return + } + if !s.firstChunkSent { + delta["role"] = "assistant" + s.firstChunkSent = true + } + s.sendChunk(openaifmt.BuildChatStreamChunk( + s.completionID, + s.created, + s.model, + []map[string]any{openaifmt.BuildChatStreamDeltaChoice(0, delta)}, + nil, + )) +} + func (s *chatStreamRuntime) sendDone() { _, _ = s.w.Write([]byte("data: [DONE]\n\n")) if s.canFlush { @@ -146,42 +190,22 @@ func (s *chatStreamRuntime) finalize(finishReason string, deferEmptyOutput bool) detected := detectAssistantToolCalls(s.rawText.String(), finalText, s.rawThinking.String(), finalToolDetectionThinking, s.toolNames) if len(detected.Calls) > 0 && !s.toolCallsDoneEmitted { finishReason = "tool_calls" - delta := map[string]any{ + s.sendDelta(map[string]any{ "tool_calls": formatFinalStreamToolCallsWithStableIDs(detected.Calls, s.streamToolCallIDs, s.toolsRaw), - } - if !s.firstChunkSent { - delta["role"] = "assistant" - s.firstChunkSent = true - } - s.sendChunk(openaifmt.BuildChatStreamChunk( - s.completionID, - s.created, - s.model, - []map[string]any{openaifmt.BuildChatStreamDeltaChoice(0, delta)}, - nil, - )) + }) s.toolCallsEmitted = true s.toolCallsDoneEmitted = true } else if s.bufferToolContent { + batch := chatDeltaBatch{runtime: s} for _, evt := range toolstream.Flush(&s.toolSieve, s.toolNames) { if len(evt.ToolCalls) > 0 { + batch.flush() finishReason = "tool_calls" s.toolCallsEmitted = true s.toolCallsDoneEmitted = true - tcDelta := map[string]any{ + s.sendDelta(map[string]any{ "tool_calls": formatFinalStreamToolCallsWithStableIDs(evt.ToolCalls, s.streamToolCallIDs, s.toolsRaw), - } - if !s.firstChunkSent { - tcDelta["role"] = "assistant" - s.firstChunkSent = true - } - s.sendChunk(openaifmt.BuildChatStreamChunk( - s.completionID, - s.created, - s.model, - []map[string]any{openaifmt.BuildChatStreamDeltaChoice(0, tcDelta)}, - nil, - )) + }) s.resetStreamToolCallState() } if evt.Content == "" { @@ -191,21 +215,9 @@ func (s *chatStreamRuntime) finalize(finishReason string, deferEmptyOutput bool) if cleaned == "" || (s.searchEnabled && sse.IsCitation(cleaned)) { continue } - delta := map[string]any{ - "content": cleaned, - } - if !s.firstChunkSent { - delta["role"] = "assistant" - s.firstChunkSent = true - } - s.sendChunk(openaifmt.BuildChatStreamChunk( - s.completionID, - s.created, - s.model, - []map[string]any{openaifmt.BuildChatStreamDeltaChoice(0, delta)}, - nil, - )) + batch.append("content", cleaned) } + batch.flush() } if len(detected.Calls) > 0 || s.toolCallsEmitted { @@ -222,7 +234,7 @@ func (s *chatStreamRuntime) finalize(finishReason string, deferEmptyOutput bool) s.sendFailedChunk(status, message, code) return true } - usage := openaifmt.BuildChatUsage(s.finalPrompt, finalThinking, finalText) + usage := openaifmt.BuildChatUsageForModel(s.model, s.finalPrompt, finalThinking, finalText, s.refFileTokens) s.finalFinishReason = finishReason s.finalUsage = usage s.sendChunk(openaifmt.BuildChatStreamChunk( @@ -256,8 +268,8 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD return streamengine.ParsedDecision{Stop: true, StopReason: streamengine.StopReasonHandlerRequested} } - newChoices := make([]map[string]any, 0, len(parsed.Parts)) contentSeen := false + batch := chatDeltaBatch{runtime: s} for _, p := range parsed.ToolDetectionThinkingParts { trimmed := sse.TrimContinuationOverlap(s.toolDetectionThinking.String(), p.Text) if trimmed != "" { @@ -265,11 +277,6 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD } } for _, p := range parsed.Parts { - delta := map[string]any{} - if !s.firstChunkSent { - delta["role"] = "assistant" - s.firstChunkSent = true - } if p.Type == "thinking" { rawTrimmed := sse.TrimContinuationOverlap(s.rawThinking.String(), p.Text) if rawTrimmed != "" { @@ -286,7 +293,7 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD continue } s.thinking.WriteString(trimmed) - delta["reasoning_content"] = trimmed + batch.append("reasoning_content", trimmed) } } else { rawTrimmed := sse.TrimContinuationOverlap(s.rawText.String(), p.Text) @@ -307,7 +314,7 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD if trimmed == "" { continue } - delta["content"] = trimmed + batch.append("content", trimmed) } else { events := toolstream.ProcessChunk(&s.toolSieve, rawTrimmed, s.toolNames) for _, evt := range events { @@ -323,28 +330,22 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD if len(formatted) == 0 { continue } + batch.flush() tcDelta := map[string]any{ "tool_calls": formatted, } s.toolCallsEmitted = true - if !s.firstChunkSent { - tcDelta["role"] = "assistant" - s.firstChunkSent = true - } - newChoices = append(newChoices, openaifmt.BuildChatStreamDeltaChoice(0, tcDelta)) + s.sendDelta(tcDelta) continue } if len(evt.ToolCalls) > 0 { + batch.flush() s.toolCallsEmitted = true s.toolCallsDoneEmitted = true tcDelta := map[string]any{ "tool_calls": formatFinalStreamToolCallsWithStableIDs(evt.ToolCalls, s.streamToolCallIDs, s.toolsRaw), } - if !s.firstChunkSent { - tcDelta["role"] = "assistant" - s.firstChunkSent = true - } - newChoices = append(newChoices, openaifmt.BuildChatStreamDeltaChoice(0, tcDelta)) + s.sendDelta(tcDelta) s.resetStreamToolCallState() continue } @@ -353,25 +354,12 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD if cleaned == "" || (s.searchEnabled && sse.IsCitation(cleaned)) { continue } - contentDelta := map[string]any{ - "content": cleaned, - } - if !s.firstChunkSent { - contentDelta["role"] = "assistant" - s.firstChunkSent = true - } - newChoices = append(newChoices, openaifmt.BuildChatStreamDeltaChoice(0, contentDelta)) + batch.append("content", cleaned) } } } } - if len(delta) > 0 { - newChoices = append(newChoices, openaifmt.BuildChatStreamDeltaChoice(0, delta)) - } - } - - if len(newChoices) > 0 { - s.sendChunk(openaifmt.BuildChatStreamChunk(s.completionID, s.created, s.model, newChoices, nil)) } + batch.flush() return streamengine.ParsedDecision{ContentSeen: contentSeen} } diff --git a/internal/httpapi/openai/chat/empty_retry_runtime.go b/internal/httpapi/openai/chat/empty_retry_runtime.go index c3d37b9..de2ff12 100644 --- a/internal/httpapi/openai/chat/empty_retry_runtime.go +++ b/internal/httpapi/openai/chat/empty_retry_runtime.go @@ -28,7 +28,7 @@ type chatNonStreamResult struct { responseMessageID int } -func (h *Handler) handleNonStreamWithRetry(w http.ResponseWriter, ctx context.Context, a *auth.RequestAuth, resp *http.Response, payload map[string]any, pow, completionID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any, historySession *chatHistorySession) { +func (h *Handler) handleNonStreamWithRetry(w http.ResponseWriter, ctx context.Context, a *auth.RequestAuth, resp *http.Response, payload map[string]any, pow, completionID, model, finalPrompt string, refFileTokens int, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any, historySession *chatHistorySession) { attempts := 0 currentResp := resp usagePrompt := finalPrompt @@ -49,9 +49,10 @@ func (h *Handler) handleNonStreamWithRetry(w http.ResponseWriter, ctx context.Co detected := detectAssistantToolCalls(result.rawText, result.text, result.rawThinking, result.toolDetectionThinking, toolNames) result.detectedCalls = len(detected.Calls) result.body = openaifmt.BuildChatCompletionWithToolCalls(completionID, model, usagePrompt, result.thinking, result.text, detected.Calls, toolsRaw) + addRefFileTokensToUsage(result.body, refFileTokens) result.finishReason = chatFinishReason(result.body) if !shouldRetryChatNonStream(result, attempts) { - h.finishChatNonStreamResult(w, result, attempts, usagePrompt, historySession) + h.finishChatNonStreamResult(w, result, attempts, usagePrompt, refFileTokens, historySession) return } @@ -72,7 +73,7 @@ func (h *Handler) handleNonStreamWithRetry(w http.ResponseWriter, ctx context.Co config.Logger.Warn("[openai_empty_retry] retry request failed", "surface", "chat.completions", "stream", false, "retry_attempt", attempts, "error", err) return } - usagePrompt = usagePromptWithEmptyOutputRetry(finalPrompt, attempts) + usagePrompt = usagePromptWithEmptyOutputRetry(usagePrompt, attempts) currentResp = nextResp } } @@ -107,7 +108,7 @@ func (h *Handler) collectChatNonStreamAttempt(w http.ResponseWriter, resp *http. }, true } -func (h *Handler) finishChatNonStreamResult(w http.ResponseWriter, result chatNonStreamResult, attempts int, usagePrompt string, historySession *chatHistorySession) { +func (h *Handler) finishChatNonStreamResult(w http.ResponseWriter, result chatNonStreamResult, attempts int, usagePrompt string, refFileTokens int, historySession *chatHistorySession) { if result.detectedCalls == 0 && shouldWriteUpstreamEmptyOutputError(result.text) { status, message, code := upstreamEmptyOutputDetail(result.contentFilter, result.text, result.thinking) if historySession != nil { @@ -118,7 +119,7 @@ func (h *Handler) finishChatNonStreamResult(w http.ResponseWriter, result chatNo return } if historySession != nil { - historySession.success(http.StatusOK, result.thinking, result.text, result.finishReason, openaifmt.BuildChatUsage(usagePrompt, result.thinking, result.text)) + historySession.success(http.StatusOK, result.thinking, result.text, result.finishReason, openaifmt.BuildChatUsageForModel("", usagePrompt, result.thinking, result.text, refFileTokens)) } writeJSON(w, http.StatusOK, result.body) source := "first_attempt" @@ -145,8 +146,8 @@ func shouldRetryChatNonStream(result chatNonStreamResult, attempts int) bool { strings.TrimSpace(result.text) == "" } -func (h *Handler) handleStreamWithRetry(w http.ResponseWriter, r *http.Request, a *auth.RequestAuth, resp *http.Response, payload map[string]any, pow, completionID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any, historySession *chatHistorySession) { - streamRuntime, initialType, ok := h.prepareChatStreamRuntime(w, resp, completionID, model, finalPrompt, thinkingEnabled, searchEnabled, toolNames, toolsRaw, historySession) +func (h *Handler) handleStreamWithRetry(w http.ResponseWriter, r *http.Request, a *auth.RequestAuth, resp *http.Response, payload map[string]any, pow, completionID, model, finalPrompt string, refFileTokens int, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any, historySession *chatHistorySession) { + streamRuntime, initialType, ok := h.prepareChatStreamRuntime(w, resp, completionID, model, finalPrompt, refFileTokens, thinkingEnabled, searchEnabled, toolNames, toolsRaw, historySession) if !ok { return } @@ -188,7 +189,7 @@ func (h *Handler) handleStreamWithRetry(w http.ResponseWriter, r *http.Request, } } -func (h *Handler) prepareChatStreamRuntime(w http.ResponseWriter, resp *http.Response, completionID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any, historySession *chatHistorySession) (*chatStreamRuntime, string, bool) { +func (h *Handler) prepareChatStreamRuntime(w http.ResponseWriter, resp *http.Response, completionID, model, finalPrompt string, refFileTokens int, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any, historySession *chatHistorySession) (*chatStreamRuntime, string, bool) { if resp.StatusCode != http.StatusOK { defer func() { _ = resp.Body.Close() }() body, _ := io.ReadAll(resp.Body) @@ -216,6 +217,7 @@ func (h *Handler) prepareChatStreamRuntime(w http.ResponseWriter, resp *http.Res thinkingEnabled, searchEnabled, h.compatStripReferenceMarkers(), toolNames, toolsRaw, len(toolNames) > 0, h.toolcallFeatureMatchEnabled() && h.toolcallEarlyEmitHighConfidence(), ) + streamRuntime.refFileTokens = refFileTokens return streamRuntime, initialType, true } diff --git a/internal/httpapi/openai/chat/handler_chat.go b/internal/httpapi/openai/chat/handler_chat.go index a2e421a..6fa1d63 100644 --- a/internal/httpapi/openai/chat/handler_chat.go +++ b/internal/httpapi/openai/chat/handler_chat.go @@ -108,11 +108,12 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) { writeOpenAIError(w, http.StatusInternalServerError, "Failed to get completion.") return } + refFileTokens := stdReq.RefFileTokens if stdReq.Stream { - h.handleStreamWithRetry(w, r, a, resp, payload, pow, sessionID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, stdReq.ToolsRaw, historySession) + h.handleStreamWithRetry(w, r, a, resp, payload, pow, sessionID, stdReq.ResponseModel, stdReq.PromptTokenText, refFileTokens, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, stdReq.ToolsRaw, historySession) return } - h.handleNonStreamWithRetry(w, r.Context(), a, resp, payload, pow, sessionID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, stdReq.ToolsRaw, historySession) + h.handleNonStreamWithRetry(w, r.Context(), a, resp, payload, pow, sessionID, stdReq.ResponseModel, stdReq.PromptTokenText, refFileTokens, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, stdReq.ToolsRaw, historySession) } func (h *Handler) autoDeleteRemoteSession(ctx context.Context, a *auth.RequestAuth, sessionID string) { @@ -148,7 +149,7 @@ func (h *Handler) autoDeleteRemoteSession(ctx context.Context, a *auth.RequestAu } } -func (h *Handler) handleNonStream(w http.ResponseWriter, resp *http.Response, completionID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any, historySession *chatHistorySession) { +func (h *Handler) handleNonStream(w http.ResponseWriter, resp *http.Response, completionID, model, finalPrompt string, refFileTokens int, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any, historySession *chatHistorySession) { if resp.StatusCode != http.StatusOK { defer func() { _ = resp.Body.Close() }() body, _ := io.ReadAll(resp.Body) @@ -176,6 +177,9 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, resp *http.Response, co return } respBody := openaifmt.BuildChatCompletionWithToolCalls(completionID, model, finalPrompt, finalThinking, finalText, detected.Calls, toolsRaw) + if refFileTokens > 0 { + addRefFileTokensToUsage(respBody, refFileTokens) + } finishReason := "stop" if choices, ok := respBody["choices"].([]map[string]any); ok && len(choices) > 0 { if fr, _ := choices[0]["finish_reason"].(string); strings.TrimSpace(fr) != "" { @@ -183,12 +187,12 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, resp *http.Response, co } } if historySession != nil { - historySession.success(http.StatusOK, finalThinking, finalText, finishReason, openaifmt.BuildChatUsage(finalPrompt, finalThinking, finalText)) + historySession.success(http.StatusOK, finalThinking, finalText, finishReason, openaifmt.BuildChatUsageForModel(model, finalPrompt, finalThinking, finalText, refFileTokens)) } writeJSON(w, http.StatusOK, respBody) } -func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *http.Response, completionID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any, historySession *chatHistorySession) { +func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *http.Response, completionID, model, finalPrompt string, refFileTokens int, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any, historySession *chatHistorySession) { defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) @@ -233,6 +237,7 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *htt bufferToolContent, emitEarlyToolDeltas, ) + streamRuntime.refFileTokens = refFileTokens streamengine.ConsumeSSE(streamengine.ConsumeConfig{ Context: r.Context(), diff --git a/internal/httpapi/openai/chat/handler_toolcall_test.go b/internal/httpapi/openai/chat/handler_toolcall_test.go index 0574b17..446b480 100644 --- a/internal/httpapi/openai/chat/handler_toolcall_test.go +++ b/internal/httpapi/openai/chat/handler_toolcall_test.go @@ -1,6 +1,7 @@ package chat import ( + "context" "encoding/json" "io" "net/http" @@ -93,7 +94,7 @@ func TestHandleNonStreamReturns429WhenUpstreamOutputEmpty(t *testing.T) { ) rec := httptest.NewRecorder() - h.handleNonStream(rec, resp, "cid-empty", "deepseek-v4-flash", "prompt", false, false, nil, nil, nil) + h.handleNonStream(rec, resp, "cid-empty", "deepseek-v4-flash", "prompt", 0, false, false, nil, nil, nil) if rec.Code != http.StatusTooManyRequests { t.Fatalf("expected status 429 for empty upstream output, got %d body=%s", rec.Code, rec.Body.String()) } @@ -112,7 +113,7 @@ func TestHandleNonStreamReturnsContentFilterErrorWhenUpstreamFilteredWithoutOutp ) rec := httptest.NewRecorder() - h.handleNonStream(rec, resp, "cid-empty-filtered", "deepseek-v4-flash", "prompt", false, false, nil, nil, nil) + h.handleNonStream(rec, resp, "cid-empty-filtered", "deepseek-v4-flash", "prompt", 0, false, false, nil, nil, nil) if rec.Code != http.StatusBadRequest { t.Fatalf("expected status 400 for filtered upstream output, got %d body=%s", rec.Code, rec.Body.String()) } @@ -131,7 +132,7 @@ func TestHandleNonStreamReturns429WhenUpstreamHasOnlyThinking(t *testing.T) { ) rec := httptest.NewRecorder() - h.handleNonStream(rec, resp, "cid-thinking-only", "deepseek-v4-pro", "prompt", true, false, nil, nil, nil) + h.handleNonStream(rec, resp, "cid-thinking-only", "deepseek-v4-pro", "prompt", 0, true, false, nil, nil, nil) if rec.Code != http.StatusTooManyRequests { t.Fatalf("expected status 429 for thinking-only upstream output, got %d body=%s", rec.Code, rec.Body.String()) } @@ -150,7 +151,7 @@ func TestHandleNonStreamPromotesThinkingToolCallsWhenTextEmpty(t *testing.T) { ) rec := httptest.NewRecorder() - h.handleNonStream(rec, resp, "cid-thinking-tool", "deepseek-v4-pro", "prompt", true, false, []string{"search"}, nil, nil) + h.handleNonStream(rec, resp, "cid-thinking-tool", "deepseek-v4-pro", "prompt", 0, true, false, []string{"search"}, nil, nil) if rec.Code != http.StatusOK { t.Fatalf("expected 200 for thinking tool calls, got %d body=%s", rec.Code, rec.Body.String()) } @@ -181,7 +182,7 @@ func TestHandleNonStreamPromotesHiddenThinkingDSMLToolCallsWhenTextEmpty(t *test ) rec := httptest.NewRecorder() - h.handleNonStream(rec, resp, "cid-hidden-thinking-tool", "deepseek-v4-pro", "prompt", false, false, []string{"search"}, nil, nil) + h.handleNonStream(rec, resp, "cid-hidden-thinking-tool", "deepseek-v4-pro", "prompt", 0, false, false, []string{"search"}, nil, nil) if rec.Code != http.StatusOK { t.Fatalf("expected 200 for hidden thinking tool calls, got %d body=%s", rec.Code, rec.Body.String()) } @@ -211,7 +212,7 @@ func TestHandleStreamToolsPlainTextStreamsBeforeFinish(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) - h.handleStream(rec, req, resp, "cid6", "deepseek-v4-flash", "prompt", false, false, []string{"search"}, nil, nil) + h.handleStream(rec, req, resp, "cid6", "deepseek-v4-flash", "prompt", 0, false, false, []string{"search"}, nil, nil) frames, done := parseSSEDataFrames(t, rec.Body.String()) if !done { @@ -239,6 +240,118 @@ func TestHandleStreamToolsPlainTextStreamsBeforeFinish(t *testing.T) { } } +func TestHandleStreamThinkingDisabledDoesNotLeakHiddenFragmentContinuations(t *testing.T) { + h := &Handler{} + resp := makeSSEHTTPResponse( + `data: {"p":"response/fragments","o":"APPEND","v":[{"type":"THINK","content":"我们"}]}`, + `data: {"p":"response/fragments/-1/content","v":"被"}`, + `data: {"v":"要求"}`, + `data: {"p":"response/fragments","o":"APPEND","v":[{"type":"RESPONSE","content":"答"}]}`, + `data: {"p":"response/fragments/-1/content","v":"案"}`, + `data: [DONE]`, + ) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + + h.handleStream(rec, req, resp, "cid-hidden-fragment", "deepseek-v4-flash", "prompt", 0, false, false, nil, nil, nil) + + frames, done := parseSSEDataFrames(t, rec.Body.String()) + if !done { + t.Fatalf("expected [DONE], body=%s", rec.Body.String()) + } + content := strings.Builder{} + for _, frame := range frames { + choices, _ := frame["choices"].([]any) + for _, item := range choices { + choice, _ := item.(map[string]any) + delta, _ := choice["delta"].(map[string]any) + if c, ok := delta["content"].(string); ok { + content.WriteString(c) + } + } + } + if got := content.String(); got != "答案" { + t.Fatalf("expected only visible response text, got %q body=%s", got, rec.Body.String()) + } +} + +func TestHandleStreamEmitsSingleChoiceFramesForMultipleParsedParts(t *testing.T) { + h := &Handler{} + resp := makeSSEHTTPResponse( + `data: {"p":"response/fragments","o":"APPEND","v":[{"type":"THINK","content":"我们"},{"type":"THINK","content":"被"},{"type":"THINK","content":"要求"},{"type":"RESPONSE","content":"答"},{"type":"RESPONSE","content":"案"}]}`, + `data: [DONE]`, + ) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + + h.handleStream(rec, req, resp, "cid-multi-parts", "deepseek-v4-pro", "prompt", 0, true, false, nil, nil, nil) + + frames, done := parseSSEDataFrames(t, rec.Body.String()) + if !done { + t.Fatalf("expected [DONE], body=%s", rec.Body.String()) + } + var reasoning, content strings.Builder + for _, frame := range frames { + choices, _ := frame["choices"].([]any) + if len(choices) != 1 { + t.Fatalf("expected exactly one choice per stream frame, got %d frame=%#v body=%s", len(choices), frame, rec.Body.String()) + } + choice, _ := choices[0].(map[string]any) + delta, _ := choice["delta"].(map[string]any) + reasoning.WriteString(asString(delta["reasoning_content"])) + content.WriteString(asString(delta["content"])) + } + if got := reasoning.String(); got != "我们被要求" { + t.Fatalf("first-choice-only client would miss reasoning tokens: got %q body=%s", got, rec.Body.String()) + } + if got := content.String(); got != "答案" { + t.Fatalf("first-choice-only client would miss content tokens: got %q body=%s", got, rec.Body.String()) + } +} + +func TestHandleStreamCoalescesSmallContentDeltas(t *testing.T) { + h := &Handler{} + lines := make([]string, 0, 101) + for i := 0; i < 100; i++ { + b, _ := json.Marshal(map[string]any{ + "p": "response/content", + "v": "字", + }) + lines = append(lines, "data: "+string(b)) + } + lines = append(lines, "data: [DONE]") + resp := makeSSEHTTPResponse(lines...) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + + h.handleStream(rec, req, resp, "cid-coalesce", "deepseek-v4-flash", "prompt", 0, false, false, nil, nil, nil) + + frames, done := parseSSEDataFrames(t, rec.Body.String()) + if !done { + t.Fatalf("expected [DONE], body=%s", rec.Body.String()) + } + var content strings.Builder + contentDeltaFrames := 0 + for _, frame := range frames { + choices, _ := frame["choices"].([]any) + if len(choices) != 1 { + t.Fatalf("expected exactly one choice per stream frame, got %d frame=%#v body=%s", len(choices), frame, rec.Body.String()) + } + choice, _ := choices[0].(map[string]any) + delta, _ := choice["delta"].(map[string]any) + if c, ok := delta["content"].(string); ok { + contentDeltaFrames++ + content.WriteString(c) + } + } + if got, want := content.String(), strings.Repeat("字", 100); got != want { + t.Fatalf("coalesced stream content mismatch: got %q want %q body=%s", got, want, rec.Body.String()) + } + if contentDeltaFrames >= 100 { + t.Fatalf("expected coalescing to reduce 100 tiny content frames, got %d body=%s", contentDeltaFrames, rec.Body.String()) + } +} + func TestHandleStreamIncompleteCapturedToolJSONFlushesAsTextOnFinalize(t *testing.T) { h := &Handler{} resp := makeSSEHTTPResponse( @@ -248,7 +361,7 @@ func TestHandleStreamIncompleteCapturedToolJSONFlushesAsTextOnFinalize(t *testin rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) - h.handleStream(rec, req, resp, "cid10", "deepseek-v4-flash", "prompt", false, false, []string{"search"}, nil, nil) + h.handleStream(rec, req, resp, "cid10", "deepseek-v4-flash", "prompt", 0, false, false, []string{"search"}, nil, nil) frames, done := parseSSEDataFrames(t, rec.Body.String()) if !done { @@ -282,7 +395,7 @@ func TestHandleStreamPromotesThinkingToolCallsOnFinalizeWithoutMidstreamIntercep rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) - h.handleStream(rec, req, resp, "cid-thinking-stream", "deepseek-v4-pro", "prompt", true, false, []string{"search"}, nil, nil) + h.handleStream(rec, req, resp, "cid-thinking-stream", "deepseek-v4-pro", "prompt", 0, true, false, []string{"search"}, nil, nil) frames, done := parseSSEDataFrames(t, rec.Body.String()) if !done { @@ -315,7 +428,7 @@ func TestHandleStreamPromotesHiddenThinkingDSMLToolCallsOnFinalize(t *testing.T) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) - h.handleStream(rec, req, resp, "cid-hidden-thinking-stream", "deepseek-v4-pro", "prompt", false, false, []string{"search"}, nil, nil) + h.handleStream(rec, req, resp, "cid-hidden-thinking-stream", "deepseek-v4-pro", "prompt", 0, false, false, []string{"search"}, nil, nil) frames, done := parseSSEDataFrames(t, rec.Body.String()) if !done { @@ -349,7 +462,7 @@ func TestHandleStreamEmitsDistinctToolCallIDsAcrossSeparateToolBlocks(t *testing rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) - h.handleStream(rec, req, resp, "cid-multi", "deepseek-v4-flash", "prompt", false, false, []string{"read_file", "search"}, nil, nil) + h.handleStream(rec, req, resp, "cid-multi", "deepseek-v4-flash", "prompt", 0, false, false, []string{"read_file", "search"}, nil, nil) frames, done := parseSSEDataFrames(t, rec.Body.String()) if !done { @@ -415,7 +528,7 @@ func TestHandleStreamCoercesSchemaDeclaredStringArgumentsOnFinalize(t *testing.T }, } - h.handleStream(rec, req, resp, "cid-string-protect", "deepseek-v4-flash", "prompt", false, false, []string{"Write"}, toolsRaw, nil) + h.handleStream(rec, req, resp, "cid-string-protect", "deepseek-v4-flash", "prompt", 0, false, false, []string{"Write"}, toolsRaw, nil) frames, done := parseSSEDataFrames(t, rec.Body.String()) if !done { @@ -447,3 +560,45 @@ func TestHandleStreamCoercesSchemaDeclaredStringArgumentsOnFinalize(t *testing.T } t.Fatalf("expected at least one streamed tool call delta, body=%s", rec.Body.String()) } + +func TestHandleNonStreamWithRetryIncludesRefFileTokensInUsage(t *testing.T) { + h := &Handler{} + + run := func(refFileTokens int) map[string]any { + resp := makeSSEHTTPResponse( + `data: {"p":"response/content","v":"hello world"}`, + `data: [DONE]`, + ) + rec := httptest.NewRecorder() + h.handleNonStreamWithRetry(rec, context.Background(), nil, resp, nil, "", "cid-ref", "deepseek-v4-flash", "prompt", refFileTokens, false, false, nil, nil, nil) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + return decodeJSONBody(t, rec.Body.String()) + } + + base := run(0) + withRef := run(7) + + baseUsage, _ := base["usage"].(map[string]any) + refUsage, _ := withRef["usage"].(map[string]any) + if baseUsage == nil || refUsage == nil { + t.Fatalf("expected usage objects, base=%#v ref=%#v", base["usage"], withRef["usage"]) + } + + getInt := func(m map[string]any, key string) int { + t.Helper() + v, ok := m[key].(float64) + if !ok { + t.Fatalf("expected numeric %s, got %#v", key, m[key]) + } + return int(v) + } + + if got := getInt(refUsage, "prompt_tokens") - getInt(baseUsage, "prompt_tokens"); got != 7 { + t.Fatalf("expected prompt_tokens delta 7, got %d", got) + } + if got := getInt(refUsage, "total_tokens") - getInt(baseUsage, "total_tokens"); got != 7 { + t.Fatalf("expected total_tokens delta 7, got %d", got) + } +} diff --git a/internal/httpapi/openai/chat/ref_file_tokens.go b/internal/httpapi/openai/chat/ref_file_tokens.go new file mode 100644 index 0000000..e5da36a --- /dev/null +++ b/internal/httpapi/openai/chat/ref_file_tokens.go @@ -0,0 +1,26 @@ +package chat + +// addRefFileTokensToUsage adds inline-uploaded file token estimates to an existing +// usage map inside a response object. This keeps the token accounting aware of file +// content that the upstream model processes but that is not part of the prompt text. +func addRefFileTokensToUsage(obj map[string]any, refFileTokens int) { + if refFileTokens <= 0 || obj == nil { + return + } + usage, ok := obj["usage"].(map[string]any) + if !ok || usage == nil { + return + } + for _, key := range []string{"input_tokens", "prompt_tokens"} { + if v, ok := usage[key]; ok { + if n, ok := v.(int); ok { + usage[key] = n + refFileTokens + } + } + } + if v, ok := usage["total_tokens"]; ok { + if n, ok := v.(int); ok { + usage["total_tokens"] = n + refFileTokens + } + } +} diff --git a/internal/httpapi/openai/file_inline_upload_test.go b/internal/httpapi/openai/file_inline_upload_test.go index 4ea2445..fa399b8 100644 --- a/internal/httpapi/openai/file_inline_upload_test.go +++ b/internal/httpapi/openai/file_inline_upload_test.go @@ -216,6 +216,45 @@ func TestChatCompletionsInlineUploadFailureReturnsBadRequest(t *testing.T) { } } +func TestChatCompletionsInlineUploadLimitReturnsBadRequest(t *testing.T) { + ds := &inlineUploadDSStub{} + h := &openAITestSurface{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds} + content := []any{map[string]any{"type": "input_text", "text": "hi"}} + for i := 0; i < 51; i++ { + content = append(content, map[string]any{ + "type": "image_url", + "image_url": map[string]any{"url": "data:image/png;base64,QUJDRA=="}, + }) + } + body, err := json.Marshal(map[string]any{ + "model": "deepseek-v4-flash", + "messages": []any{map[string]any{ + "role": "user", + "content": content, + }}, + "stream": false, + }) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(string(body))) + req.Header.Set("Authorization", "Bearer direct-token") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + h.ChatCompletions(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "exceeded maximum of 50 inline files per request") { + t.Fatalf("expected inline file limit error, got body=%s", rec.Body.String()) + } + if ds.completionReq != nil { + t.Fatalf("did not expect completion call after inline file limit error") + } +} + func TestResponsesInlineUploadFailureReturnsInternalServerError(t *testing.T) { ds := &inlineUploadDSStub{uploadErr: errors.New("boom")} h := &openAITestSurface{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds} diff --git a/internal/httpapi/openai/files/file_inline_upload.go b/internal/httpapi/openai/files/file_inline_upload.go index c8d59a9..a16fe52 100644 --- a/internal/httpapi/openai/files/file_inline_upload.go +++ b/internal/httpapi/openai/files/file_inline_upload.go @@ -39,11 +39,12 @@ func (e *inlineFileUploadError) Error() string { } type inlineUploadState struct { - ctx context.Context - handler *Handler - auth *auth.RequestAuth - uploadedByID map[string]string - uploadCount int + ctx context.Context + handler *Handler + auth *auth.RequestAuth + uploadedByID map[string]string + uploadCount int + inlineFileBytes int } type inlineDecodedFile struct { @@ -75,6 +76,9 @@ func (h *Handler) PreprocessInlineFileInputs(ctx context.Context, a *auth.Reques if refIDs := promptcompat.CollectOpenAIRefFileIDs(req); len(refIDs) > 0 { req["ref_file_ids"] = stringsToAnySlice(refIDs) } + if state.inlineFileBytes > 0 { + req["_inline_file_bytes"] = state.inlineFileBytes + } return nil } @@ -135,13 +139,15 @@ func (s *inlineUploadState) tryUploadBlock(block map[string]any) (map[string]any return nil, false, nil } if s.uploadCount >= maxInlineFilesPerRequest { - return nil, true, fmt.Errorf("exceeded maximum of %d inline files per request", maxInlineFilesPerRequest) + err := fmt.Errorf("exceeded maximum of %d inline files per request", maxInlineFilesPerRequest) + return nil, true, &inlineFileUploadError{status: http.StatusBadRequest, message: err.Error(), err: err} } fileID, err := s.uploadInlineFile(decoded) if err != nil { return nil, true, &inlineFileUploadError{status: http.StatusInternalServerError, message: "Failed to upload inline file.", err: err} } s.uploadCount++ + s.inlineFileBytes += len(decoded.Data) replacement := map[string]any{ "type": decoded.ReplacementType, "file_id": fileID, diff --git a/internal/httpapi/openai/history/current_input_file.go b/internal/httpapi/openai/history/current_input_file.go index 8a24575..181a5e2 100644 --- a/internal/httpapi/openai/history/current_input_file.go +++ b/internal/httpapi/openai/history/current_input_file.go @@ -35,7 +35,6 @@ func (s Service) ApplyCurrentInputFile(ctx context.Context, a *auth.RequestAuth, if strings.TrimSpace(fileText) == "" { return stdReq, errors.New("current user input file produced empty transcript") } - result, err := s.DS.UploadFile(ctx, a, dsclient.UploadFileRequest{ Filename: currentInputFilename, ContentType: currentInputContentType, @@ -62,6 +61,9 @@ func (s Service) ApplyCurrentInputFile(ctx context.Context, a *auth.RequestAuth, stdReq.CurrentInputFileApplied = true stdReq.RefFileIDs = prependUniqueRefFileID(stdReq.RefFileIDs, fileID) stdReq.FinalPrompt, stdReq.ToolNames = promptcompat.BuildOpenAIPrompt(messages, stdReq.ToolsRaw, "", stdReq.ToolChoice, stdReq.Thinking) + // Token accounting must reflect the actual downstream context: + // the uploaded history.txt file content + the neutral live prompt. + stdReq.PromptTokenText = fileText + "\n" + stdReq.FinalPrompt return stdReq, nil } diff --git a/internal/httpapi/openai/history_split_test.go b/internal/httpapi/openai/history_split_test.go index 593735a..d429b9b 100644 --- a/internal/httpapi/openai/history_split_test.go +++ b/internal/httpapi/openai/history_split_test.go @@ -14,6 +14,7 @@ import ( "ds2api/internal/auth" dsclient "ds2api/internal/deepseek/client" "ds2api/internal/promptcompat" + "ds2api/internal/util" ) func historySplitTestMessages() []any { @@ -298,6 +299,52 @@ func TestApplyCurrentInputFileUploadsFirstTurnWithInjectedWrapper(t *testing.T) if len(out.RefFileIDs) != 1 || out.RefFileIDs[0] != "file-inline-1" { t.Fatalf("expected current input file id in ref_file_ids, got %#v", out.RefFileIDs) } + if !strings.Contains(out.PromptTokenText, "first turn content that is long enough") { + t.Fatalf("expected prompt token text to preserve original full context, got %q", out.PromptTokenText) + } +} + +func TestApplyCurrentInputFilePreservesFullContextPromptForTokenCounting(t *testing.T) { + ds := &inlineUploadDSStub{} + h := &openAITestSurface{ + Store: mockOpenAIConfig{ + wideInput: true, + currentInputEnabled: true, + currentInputMin: 0, + thinkingInjection: boolPtr(true), + }, + DS: ds, + } + req := map[string]any{ + "model": "deepseek-v4-flash", + "messages": historySplitTestMessages(), + } + stdReq, err := promptcompat.NormalizeOpenAIChatRequest(h.Store, req, "") + if err != nil { + t.Fatalf("normalize failed: %v", err) + } + + out, err := h.applyCurrentInputFile(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq) + if err != nil { + t.Fatalf("apply current input file failed: %v", err) + } + if out.FinalPrompt == stdReq.FinalPrompt { + t.Fatalf("expected live prompt to be rewritten after current input file") + } + // PromptTokenText must include the uploaded file content (which contains the full context) + // plus the neutral live prompt — reflecting the actual downstream token cost. + if !strings.Contains(out.PromptTokenText, "first user turn") || !strings.Contains(out.PromptTokenText, "latest user turn") { + t.Fatalf("expected prompt token text to contain file context with full conversation, got %q", out.PromptTokenText) + } + if strings.Contains(out.PromptTokenText, "[file content end]") || strings.Contains(out.PromptTokenText, "[file name]:") { + t.Fatalf("expected prompt token text to use raw transcript without wrapper tags, got %q", out.PromptTokenText) + } + if !strings.Contains(out.PromptTokenText, "Answer the latest user request directly.") { + t.Fatalf("expected prompt token text to also include neutral live prompt, got %q", out.PromptTokenText) + } + if strings.Contains(out.FinalPrompt, "first user turn") || strings.Contains(out.FinalPrompt, "latest user turn") { + t.Fatalf("expected live prompt to hide original turns, got %q", out.FinalPrompt) + } } func TestApplyCurrentInputFileUploadsFullContextFile(t *testing.T) { @@ -434,6 +481,16 @@ func TestChatCompletionsCurrentInputFileUploadsContextAndKeepsNeutralPrompt(t *t if len(refIDs) == 0 || refIDs[0] != "file-inline-1" { t.Fatalf("expected uploaded current input file to be first ref_file_id, got %#v", ds.completionReq["ref_file_ids"]) } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response failed: %v", err) + } + usage, _ := body["usage"].(map[string]any) + promptTokens := int(usage["prompt_tokens"].(float64)) + neutralCount := util.CountPromptTokens(promptText, "deepseek-v4-flash") + if promptTokens <= neutralCount { + t.Fatalf("expected prompt_tokens to exceed neutral live prompt count (includes file context), got=%d neutral=%d", promptTokens, neutralCount) + } } func TestResponsesCurrentInputFileUploadsContextAndKeepsNeutralPrompt(t *testing.T) { @@ -476,6 +533,16 @@ func TestResponsesCurrentInputFileUploadsContextAndKeepsNeutralPrompt(t *testing if strings.Contains(promptText, "first user turn") || strings.Contains(promptText, "latest user turn") { t.Fatalf("expected prompt to hide original turns, got %s", promptText) } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response failed: %v", err) + } + usage, _ := body["usage"].(map[string]any) + inputTokens := int(usage["input_tokens"].(float64)) + neutralCount := util.CountPromptTokens(promptText, "deepseek-v4-flash") + if inputTokens <= neutralCount { + t.Fatalf("expected input_tokens to exceed neutral live prompt count (includes file context), got=%d neutral=%d", inputTokens, neutralCount) + } } func TestChatCompletionsCurrentInputFileMapsManagedAuthFailureTo401(t *testing.T) { diff --git a/internal/httpapi/openai/responses/empty_retry_runtime.go b/internal/httpapi/openai/responses/empty_retry_runtime.go index a451c92..627f074 100644 --- a/internal/httpapi/openai/responses/empty_retry_runtime.go +++ b/internal/httpapi/openai/responses/empty_retry_runtime.go @@ -29,7 +29,7 @@ type responsesNonStreamResult struct { responseMessageID int } -func (h *Handler) handleResponsesNonStreamWithRetry(w http.ResponseWriter, ctx context.Context, a *auth.RequestAuth, resp *http.Response, payload map[string]any, pow, owner, responseID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any, toolChoice promptcompat.ToolChoicePolicy, traceID string) { +func (h *Handler) handleResponsesNonStreamWithRetry(w http.ResponseWriter, ctx context.Context, a *auth.RequestAuth, resp *http.Response, payload map[string]any, pow, owner, responseID, model, finalPrompt string, refFileTokens int, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any, toolChoice promptcompat.ToolChoicePolicy, traceID string) { attempts := 0 currentResp := resp usagePrompt := finalPrompt @@ -49,6 +49,9 @@ func (h *Handler) handleResponsesNonStreamWithRetry(w http.ResponseWriter, ctx c result.toolDetectionThinking = accumulatedToolDetectionThinking result.parsed = detectAssistantToolCalls(result.rawText, result.text, result.rawThinking, result.toolDetectionThinking, toolNames) result.body = openaifmt.BuildResponseObjectWithToolCalls(responseID, model, usagePrompt, result.thinking, result.text, result.parsed.Calls, toolsRaw) + if refFileTokens > 0 { + addRefFileTokensToUsage(result.body, refFileTokens) + } if !shouldRetryResponsesNonStream(result, attempts) { h.finishResponsesNonStreamResult(w, result, attempts, owner, responseID, toolChoice, traceID) @@ -68,7 +71,7 @@ func (h *Handler) handleResponsesNonStreamWithRetry(w http.ResponseWriter, ctx c config.Logger.Warn("[openai_empty_retry] retry request failed", "surface", "responses", "stream", false, "retry_attempt", attempts, "error", err) return } - usagePrompt = usagePromptWithEmptyOutputRetry(finalPrompt, attempts) + usagePrompt = usagePromptWithEmptyOutputRetry(usagePrompt, attempts) currentResp = nextResp } } @@ -129,8 +132,8 @@ func shouldRetryResponsesNonStream(result responsesNonStreamResult, attempts int strings.TrimSpace(result.text) == "" } -func (h *Handler) handleResponsesStreamWithRetry(w http.ResponseWriter, r *http.Request, a *auth.RequestAuth, resp *http.Response, payload map[string]any, pow, owner, responseID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any, toolChoice promptcompat.ToolChoicePolicy, traceID string) { - streamRuntime, initialType, ok := h.prepareResponsesStreamRuntime(w, resp, owner, responseID, model, finalPrompt, thinkingEnabled, searchEnabled, toolNames, toolsRaw, toolChoice, traceID) +func (h *Handler) handleResponsesStreamWithRetry(w http.ResponseWriter, r *http.Request, a *auth.RequestAuth, resp *http.Response, payload map[string]any, pow, owner, responseID, model, finalPrompt string, refFileTokens int, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any, toolChoice promptcompat.ToolChoicePolicy, traceID string) { + streamRuntime, initialType, ok := h.prepareResponsesStreamRuntime(w, resp, owner, responseID, model, finalPrompt, refFileTokens, thinkingEnabled, searchEnabled, toolNames, toolsRaw, toolChoice, traceID) if !ok { return } @@ -171,7 +174,7 @@ func (h *Handler) handleResponsesStreamWithRetry(w http.ResponseWriter, r *http. } } -func (h *Handler) prepareResponsesStreamRuntime(w http.ResponseWriter, resp *http.Response, owner, responseID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any, toolChoice promptcompat.ToolChoicePolicy, traceID string) (*responsesStreamRuntime, string, bool) { +func (h *Handler) prepareResponsesStreamRuntime(w http.ResponseWriter, resp *http.Response, owner, responseID, model, finalPrompt string, refFileTokens int, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any, toolChoice promptcompat.ToolChoicePolicy, traceID string) (*responsesStreamRuntime, string, bool) { if resp.StatusCode != http.StatusOK { defer func() { _ = resp.Body.Close() }() body, _ := io.ReadAll(resp.Body) @@ -196,6 +199,7 @@ func (h *Handler) prepareResponsesStreamRuntime(w http.ResponseWriter, resp *htt h.getResponseStore().put(owner, responseID, obj) }, ) + streamRuntime.refFileTokens = refFileTokens streamRuntime.sendCreated() return streamRuntime, initialType, true } diff --git a/internal/httpapi/openai/responses/ref_file_tokens.go b/internal/httpapi/openai/responses/ref_file_tokens.go new file mode 100644 index 0000000..a530340 --- /dev/null +++ b/internal/httpapi/openai/responses/ref_file_tokens.go @@ -0,0 +1,26 @@ +package responses + +// addRefFileTokensToUsage adds inline-uploaded file token estimates to an existing +// usage map inside a response object. This keeps the token accounting aware of file +// content that the upstream model processes but that is not part of the prompt text. +func addRefFileTokensToUsage(obj map[string]any, refFileTokens int) { + if refFileTokens <= 0 || obj == nil { + return + } + usage, ok := obj["usage"].(map[string]any) + if !ok || usage == nil { + return + } + for _, key := range []string{"input_tokens", "prompt_tokens"} { + if v, ok := usage[key]; ok { + if n, ok := v.(int); ok { + usage[key] = n + refFileTokens + } + } + } + if v, ok := usage["total_tokens"]; ok { + if n, ok := v.(int); ok { + usage["total_tokens"] = n + refFileTokens + } + } +} diff --git a/internal/httpapi/openai/responses/responses_handler.go b/internal/httpapi/openai/responses/responses_handler.go index a04e7b1..3fc1561 100644 --- a/internal/httpapi/openai/responses/responses_handler.go +++ b/internal/httpapi/openai/responses/responses_handler.go @@ -114,14 +114,15 @@ func (h *Handler) Responses(w http.ResponseWriter, r *http.Request) { } responseID := "resp_" + strings.ReplaceAll(uuid.NewString(), "-", "") + refFileTokens := stdReq.RefFileTokens if stdReq.Stream { - h.handleResponsesStreamWithRetry(w, r, a, resp, payload, pow, owner, responseID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, stdReq.ToolsRaw, stdReq.ToolChoice, traceID) + h.handleResponsesStreamWithRetry(w, r, a, resp, payload, pow, owner, responseID, stdReq.ResponseModel, stdReq.PromptTokenText, refFileTokens, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, stdReq.ToolsRaw, stdReq.ToolChoice, traceID) return } - h.handleResponsesNonStreamWithRetry(w, r.Context(), a, resp, payload, pow, owner, responseID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, stdReq.ToolsRaw, stdReq.ToolChoice, traceID) + h.handleResponsesNonStreamWithRetry(w, r.Context(), a, resp, payload, pow, owner, responseID, stdReq.ResponseModel, stdReq.PromptTokenText, refFileTokens, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, stdReq.ToolsRaw, stdReq.ToolChoice, traceID) } -func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Response, owner, responseID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any, toolChoice promptcompat.ToolChoicePolicy, traceID string) { +func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Response, owner, responseID, model, finalPrompt string, refFileTokens int, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any, toolChoice promptcompat.ToolChoicePolicy, traceID string) { defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) @@ -148,11 +149,14 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res } responseObj := openaifmt.BuildResponseObjectWithToolCalls(responseID, model, finalPrompt, sanitizedThinking, sanitizedText, textParsed.Calls, toolsRaw) + if refFileTokens > 0 { + addRefFileTokensToUsage(responseObj, refFileTokens) + } h.getResponseStore().put(owner, responseID, responseObj) writeJSON(w, http.StatusOK, responseObj) } -func (h *Handler) handleResponsesStream(w http.ResponseWriter, r *http.Request, resp *http.Response, owner, responseID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any, toolChoice promptcompat.ToolChoicePolicy, traceID string) { +func (h *Handler) handleResponsesStream(w http.ResponseWriter, r *http.Request, resp *http.Response, owner, responseID, model, finalPrompt string, refFileTokens int, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any, toolChoice promptcompat.ToolChoicePolicy, traceID string) { defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) @@ -194,6 +198,7 @@ func (h *Handler) handleResponsesStream(w http.ResponseWriter, r *http.Request, h.getResponseStore().put(owner, responseID, obj) }, ) + streamRuntime.refFileTokens = refFileTokens streamRuntime.sendCreated() streamengine.ConsumeSSE(streamengine.ConsumeConfig{ diff --git a/internal/httpapi/openai/responses/responses_stream_delta_batch.go b/internal/httpapi/openai/responses/responses_stream_delta_batch.go new file mode 100644 index 0000000..84c8c7f --- /dev/null +++ b/internal/httpapi/openai/responses/responses_stream_delta_batch.go @@ -0,0 +1,39 @@ +package responses + +import ( + "strings" + + openaifmt "ds2api/internal/format/openai" +) + +type responsesDeltaBatch struct { + runtime *responsesStreamRuntime + kind string + text strings.Builder +} + +func (b *responsesDeltaBatch) append(kind, text string) { + if text == "" { + return + } + if b.kind != "" && b.kind != kind { + b.flush() + } + b.kind = kind + b.text.WriteString(text) +} + +func (b *responsesDeltaBatch) flush() { + if b.kind == "" || b.text.Len() == 0 { + return + } + text := b.text.String() + switch b.kind { + case "reasoning": + b.runtime.sendEvent("response.reasoning.delta", openaifmt.BuildResponsesReasoningDeltaPayload(b.runtime.responseID, text)) + case "text": + b.runtime.emitTextDelta(text) + } + b.kind = "" + b.text.Reset() +} diff --git a/internal/httpapi/openai/responses/responses_stream_runtime_core.go b/internal/httpapi/openai/responses/responses_stream_runtime_core.go index 2047dfe..7184c3f 100644 --- a/internal/httpapi/openai/responses/responses_stream_runtime_core.go +++ b/internal/httpapi/openai/responses/responses_stream_runtime_core.go @@ -18,13 +18,14 @@ type responsesStreamRuntime struct { rc *http.ResponseController canFlush bool - responseID string - model string - finalPrompt string - toolNames []string - toolsRaw any - traceID string - toolChoice promptcompat.ToolChoicePolicy + responseID string + model string + finalPrompt string + refFileTokens int + toolNames []string + toolsRaw any + traceID string + toolChoice promptcompat.ToolChoicePolicy thinkingEnabled bool searchEnabled bool @@ -221,6 +222,7 @@ func (s *responsesStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Pa } contentSeen := false + batch := responsesDeltaBatch{runtime: s} for _, p := range parsed.ToolDetectionThinkingParts { trimmed := sse.TrimContinuationOverlap(s.toolDetectionThinking.String(), p.Text) if trimmed != "" { @@ -246,7 +248,7 @@ func (s *responsesStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Pa continue } s.thinking.WriteString(trimmed) - s.sendEvent("response.reasoning.delta", openaifmt.BuildResponsesReasoningDeltaPayload(s.responseID, trimmed)) + batch.append("reasoning", trimmed) continue } @@ -268,11 +270,13 @@ func (s *responsesStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Pa if trimmed == "" { continue } - s.emitTextDelta(trimmed) + batch.append("text", trimmed) continue } + batch.flush() s.processToolStreamEvents(toolstream.ProcessChunk(&s.sieve, rawTrimmed, s.toolNames), true, true) } + batch.flush() return streamengine.ParsedDecision{ContentSeen: contentSeen} } diff --git a/internal/httpapi/openai/responses/responses_stream_runtime_toolcalls_finalize.go b/internal/httpapi/openai/responses/responses_stream_runtime_toolcalls_finalize.go index 34e35e5..2f03dd3 100644 --- a/internal/httpapi/openai/responses/responses_stream_runtime_toolcalls_finalize.go +++ b/internal/httpapi/openai/responses/responses_stream_runtime_toolcalls_finalize.go @@ -145,7 +145,7 @@ func (s *responsesStreamRuntime) buildCompletedResponseObject(finalThinking, fin } } - return openaifmt.BuildResponseObjectFromItems( + obj := openaifmt.BuildResponseObjectFromItems( s.responseID, s.model, s.finalPrompt, @@ -154,4 +154,8 @@ func (s *responsesStreamRuntime) buildCompletedResponseObject(finalThinking, fin output, outputText, ) + if s.refFileTokens > 0 { + addRefFileTokensToUsage(obj, s.refFileTokens) + } + return obj } diff --git a/internal/httpapi/openai/responses/responses_stream_test.go b/internal/httpapi/openai/responses/responses_stream_test.go index 80ae5c7..fa06bd5 100644 --- a/internal/httpapi/openai/responses/responses_stream_test.go +++ b/internal/httpapi/openai/responses/responses_stream_test.go @@ -27,7 +27,7 @@ func TestHandleResponsesStreamDoesNotEmitReasoningTextCompatEvents(t *testing.T) Body: io.NopCloser(strings.NewReader(streamBody)), } - h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-pro", "prompt", true, false, nil, nil, promptcompat.DefaultToolChoicePolicy(), "") + h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-pro", "prompt", 0, true, false, nil, nil, promptcompat.DefaultToolChoicePolicy(), "") body := rec.Body.String() if !strings.Contains(body, "event: response.reasoning.delta") { @@ -57,7 +57,7 @@ func TestHandleResponsesStreamEmitsOutputTextDoneBeforeContentPartDone(t *testin Body: io.NopCloser(strings.NewReader(streamBody)), } - h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", false, false, nil, nil, promptcompat.DefaultToolChoicePolicy(), "") + h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", 0, false, false, nil, nil, promptcompat.DefaultToolChoicePolicy(), "") body := rec.Body.String() if !strings.Contains(body, "event: response.output_text.done") { t.Fatalf("expected response.output_text.done payload, body=%s", body) @@ -91,7 +91,7 @@ func TestHandleResponsesStreamOutputTextDeltaCarriesItemIndexes(t *testing.T) { Body: io.NopCloser(strings.NewReader(streamBody)), } - h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", false, false, nil, nil, promptcompat.DefaultToolChoicePolicy(), "") + h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", 0, false, false, nil, nil, promptcompat.DefaultToolChoicePolicy(), "") body := rec.Body.String() deltaPayload, ok := extractSSEEventPayload(body, "response.output_text.delta") @@ -109,6 +109,48 @@ func TestHandleResponsesStreamOutputTextDeltaCarriesItemIndexes(t *testing.T) { } } +func TestHandleResponsesStreamCoalescesSmallOutputTextDeltas(t *testing.T) { + h := &Handler{} + req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + rec := httptest.NewRecorder() + + var streamBody strings.Builder + for i := 0; i < 100; i++ { + b, _ := json.Marshal(map[string]any{ + "p": "response/content", + "v": "字", + }) + streamBody.WriteString("data: ") + streamBody.WriteString(string(b)) + streamBody.WriteString("\n") + } + streamBody.WriteString("data: [DONE]\n") + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(streamBody.String())), + } + + h.handleResponsesStream(rec, req, resp, "owner-a", "resp_coalesce", "deepseek-v4-flash", "prompt", 0, false, false, nil, nil, promptcompat.DefaultToolChoicePolicy(), "") + + payloads := extractSSEEventPayloads(rec.Body.String(), "response.output_text.delta") + if len(payloads) == 0 { + t.Fatalf("expected response.output_text.delta payloads, body=%s", rec.Body.String()) + } + var content strings.Builder + for _, payload := range payloads { + content.WriteString(asString(payload["delta"])) + } + if got, want := content.String(), strings.Repeat("字", 100); got != want { + t.Fatalf("coalesced response content mismatch: got %q want %q body=%s", got, want, rec.Body.String()) + } + if len(payloads) >= 100 { + t.Fatalf("expected coalescing to reduce 100 tiny text deltas, got %d body=%s", len(payloads), rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "event: response.completed") { + t.Fatalf("expected completed event, body=%s", rec.Body.String()) + } +} + func TestHandleResponsesStreamEmitsDistinctToolCallIDsAcrossSeparateToolBlocks(t *testing.T) { h := &Handler{} req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) @@ -130,7 +172,7 @@ func TestHandleResponsesStreamEmitsDistinctToolCallIDsAcrossSeparateToolBlocks(t Body: io.NopCloser(strings.NewReader(streamBody)), } - h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", false, false, []string{"read_file", "search"}, nil, promptcompat.DefaultToolChoicePolicy(), "") + h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", 0, false, false, []string{"read_file", "search"}, nil, promptcompat.DefaultToolChoicePolicy(), "") body := rec.Body.String() doneEvents := extractSSEEventPayloads(body, "response.function_call_arguments.done") @@ -183,7 +225,7 @@ func TestHandleResponsesStreamRequiredToolChoiceFailure(t *testing.T) { Mode: promptcompat.ToolChoiceRequired, Allowed: map[string]struct{}{"read_file": {}}, } - h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", false, false, []string{"read_file"}, nil, policy, "") + h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", 0, false, false, []string{"read_file"}, nil, policy, "") body := rec.Body.String() if !strings.Contains(body, "event: response.failed") { @@ -213,7 +255,7 @@ func TestHandleResponsesStreamFailsWhenUpstreamHasOnlyThinking(t *testing.T) { Body: io.NopCloser(strings.NewReader(streamBody)), } - h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-pro", "prompt", true, false, nil, nil, promptcompat.DefaultToolChoicePolicy(), "") + h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-pro", "prompt", 0, true, false, nil, nil, promptcompat.DefaultToolChoicePolicy(), "") body := rec.Body.String() if !strings.Contains(body, "event: response.failed") { @@ -251,7 +293,7 @@ func TestHandleResponsesStreamPromotesThinkingToolCallsOnFinalizeWithoutMidstrea Body: io.NopCloser(strings.NewReader(streamBody)), } - h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-pro", "prompt", true, false, []string{"read_file"}, nil, promptcompat.DefaultToolChoicePolicy(), "") + h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-pro", "prompt", 0, true, false, []string{"read_file"}, nil, promptcompat.DefaultToolChoicePolicy(), "") body := rec.Body.String() if strings.Contains(body, "event: response.reasoning.delta") { @@ -288,7 +330,7 @@ func TestHandleResponsesStreamPromotesHiddenThinkingDSMLToolCallsOnFinalize(t *t Mode: promptcompat.ToolChoiceRequired, Allowed: map[string]struct{}{"read_file": {}}, } - h.handleResponsesStream(rec, req, resp, "owner-a", "resp_hidden", "deepseek-v4-pro", "prompt", false, false, []string{"read_file"}, nil, policy, "") + h.handleResponsesStream(rec, req, resp, "owner-a", "resp_hidden", "deepseek-v4-pro", "prompt", 0, false, false, []string{"read_file"}, nil, policy, "") body := rec.Body.String() if strings.Contains(body, "event: response.reasoning.delta") { @@ -317,7 +359,7 @@ func TestHandleResponsesNonStreamRequiredToolChoiceViolation(t *testing.T) { Allowed: map[string]struct{}{"read_file": {}}, } - h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", false, false, []string{"read_file"}, nil, policy, "") + h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", 0, false, false, []string{"read_file"}, nil, policy, "") if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("expected 422 for required tool_choice violation, got %d body=%s", rec.Code, rec.Body.String()) } @@ -344,7 +386,7 @@ func TestHandleResponsesNonStreamRequiredToolChoiceIgnoresThinkingToolPayloadWhe Allowed: map[string]struct{}{"read_file": {}}, } - h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", true, false, []string{"read_file"}, nil, policy, "") + h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", 0, true, false, []string{"read_file"}, nil, policy, "") if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("expected 422 for required tool_choice violation, got %d body=%s", rec.Code, rec.Body.String()) } @@ -366,7 +408,7 @@ func TestHandleResponsesNonStreamReturns429WhenUpstreamOutputEmpty(t *testing.T) )), } - h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", false, false, nil, nil, promptcompat.DefaultToolChoicePolicy(), "") + h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", 0, false, false, nil, nil, promptcompat.DefaultToolChoicePolicy(), "") if rec.Code != http.StatusTooManyRequests { t.Fatalf("expected 429 for empty upstream output, got %d body=%s", rec.Code, rec.Body.String()) } @@ -388,7 +430,7 @@ func TestHandleResponsesNonStreamReturnsContentFilterErrorWhenUpstreamFilteredWi )), } - h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", false, false, nil, nil, promptcompat.DefaultToolChoicePolicy(), "") + h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-v4-flash", "prompt", 0, false, false, nil, nil, promptcompat.DefaultToolChoicePolicy(), "") if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400 for filtered empty upstream output, got %d body=%s", rec.Code, rec.Body.String()) } @@ -410,7 +452,7 @@ func TestHandleResponsesNonStreamReturns429WhenUpstreamHasOnlyThinking(t *testin )), } - h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-v4-pro", "prompt", true, false, nil, nil, promptcompat.DefaultToolChoicePolicy(), "") + h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-v4-pro", "prompt", 0, true, false, nil, nil, promptcompat.DefaultToolChoicePolicy(), "") if rec.Code != http.StatusTooManyRequests { t.Fatalf("expected 429 for thinking-only upstream output, got %d body=%s", rec.Code, rec.Body.String()) } @@ -432,7 +474,7 @@ func TestHandleResponsesNonStreamPromotesThinkingToolCallsWhenTextEmpty(t *testi )), } - h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-v4-pro", "prompt", true, false, []string{"read_file"}, nil, promptcompat.DefaultToolChoicePolicy(), "") + h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-v4-pro", "prompt", 0, true, false, []string{"read_file"}, nil, promptcompat.DefaultToolChoicePolicy(), "") if rec.Code != http.StatusOK { t.Fatalf("expected 200 for thinking tool calls, got %d body=%s", rec.Code, rec.Body.String()) } @@ -462,7 +504,7 @@ func TestHandleResponsesNonStreamPromotesHiddenThinkingDSMLToolCallsWhenTextEmpt Mode: promptcompat.ToolChoiceRequired, Allowed: map[string]struct{}{"read_file": {}}, } - h.handleResponsesNonStream(rec, resp, "owner-a", "resp_hidden", "deepseek-v4-pro", "prompt", false, false, []string{"read_file"}, nil, policy, "") + h.handleResponsesNonStream(rec, resp, "owner-a", "resp_hidden", "deepseek-v4-pro", "prompt", 0, false, false, []string{"read_file"}, nil, policy, "") if rec.Code != http.StatusOK { t.Fatalf("expected 200 for hidden thinking tool calls, got %d body=%s", rec.Code, rec.Body.String()) } @@ -509,7 +551,7 @@ func TestHandleResponsesStreamCoercesSchemaDeclaredStringArguments(t *testing.T) Body: io.NopCloser(strings.NewReader(streamBody)), } - h.handleResponsesStream(rec, req, resp, "owner-a", "resp_string_protect", "deepseek-v4-flash", "prompt", false, false, []string{"Write"}, toolsRaw, promptcompat.DefaultToolChoicePolicy(), "") + h.handleResponsesStream(rec, req, resp, "owner-a", "resp_string_protect", "deepseek-v4-flash", "prompt", 0, false, false, []string{"Write"}, toolsRaw, promptcompat.DefaultToolChoicePolicy(), "") payload, ok := extractSSEEventPayload(rec.Body.String(), "response.function_call_arguments.done") if !ok { diff --git a/internal/js/chat-stream/sse_parse_impl.js b/internal/js/chat-stream/sse_parse_impl.js index 7c6cfae..aff7104 100644 --- a/internal/js/chat-stream/sse_parse_impl.js +++ b/internal/js/chat-stream/sse_parse_impl.js @@ -70,7 +70,6 @@ function finalizeThinkingParts(parts, thinkingEnabled, newType) { } if (!thinkingEnabled) { finalParts = dropThinkingParts(finalParts); - finalType = 'text'; } return { parts: finalParts, newType: finalType }; } @@ -213,6 +212,12 @@ function parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenc } } + if (pathValue === 'response/content') { + newType = 'text'; + } else if (pathValue === 'response/thinking_content' && (!thinkingEnabled || newType !== 'text')) { + newType = 'thinking'; + } + let partType = 'text'; if (pathValue === 'response/thinking_content') { if (!thinkingEnabled) { @@ -226,8 +231,8 @@ function parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenc partType = 'text'; } else if (pathValue.includes('response/fragments') && pathValue.includes('/content')) { partType = newType; - } else if (!pathValue && thinkingEnabled) { - partType = newType; + } else if (!pathValue) { + partType = newType || 'text'; } const val = chunk.v; @@ -308,6 +313,10 @@ function parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenc } if (val && typeof val === 'object') { + const directContent = asContentString(val, stripReferenceMarkers); + if (directContent) { + parts.push({ text: directContent, type: partType }); + } const resp = val.response && typeof val.response === 'object' ? val.response : val; if (Array.isArray(resp.fragments)) { for (const frag of resp.fragments) { @@ -593,6 +602,12 @@ function asContentString(v, stripReferenceMarkers = true) { if (Object.prototype.hasOwnProperty.call(v, 'v')) { return asContentString(v.v, stripReferenceMarkers); } + if (Object.prototype.hasOwnProperty.call(v, 'text')) { + return asContentString(v.text, stripReferenceMarkers); + } + if (Object.prototype.hasOwnProperty.call(v, 'value')) { + return asContentString(v.value, stripReferenceMarkers); + } return ''; } if (v == null) { diff --git a/internal/js/chat-stream/stream_emitter.js b/internal/js/chat-stream/stream_emitter.js index 442c24e..0046807 100644 --- a/internal/js/chat-stream/stream_emitter.js +++ b/internal/js/chat-stream/stream_emitter.js @@ -1,5 +1,8 @@ 'use strict'; +const MIN_DELTA_FLUSH_CHARS = 160; +const MAX_DELTA_FLUSH_WAIT_MS = 80; + function createChatCompletionEmitter({ res, sessionID, created, model, isClosed }) { let firstChunkSent = false; @@ -34,6 +37,62 @@ function createChatCompletionEmitter({ res, sessionID, created, model, isClosed }; } +function createDeltaCoalescer({ sendDeltaFrame, minFlushChars = MIN_DELTA_FLUSH_CHARS, maxFlushWaitMS = MAX_DELTA_FLUSH_WAIT_MS }) { + let pendingField = ''; + let pendingText = ''; + let flushTimer = null; + + const clearFlushTimer = () => { + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + }; + + const flush = () => { + clearFlushTimer(); + if (!pendingField || !pendingText) { + return; + } + const delta = { [pendingField]: pendingText }; + pendingField = ''; + pendingText = ''; + sendDeltaFrame(delta); + }; + + const scheduleFlush = () => { + if (flushTimer || maxFlushWaitMS <= 0) { + return; + } + flushTimer = setTimeout(flush, maxFlushWaitMS); + if (typeof flushTimer.unref === 'function') { + flushTimer.unref(); + } + }; + + const append = (field, text) => { + if (!field || !text) { + return; + } + if (pendingField && pendingField !== field) { + flush(); + } + pendingField = field; + pendingText += text; + if ([...pendingText].length >= minFlushChars) { + flush(); + return; + } + scheduleFlush(); + }; + + return { + append, + flush, + }; +} + module.exports = { createChatCompletionEmitter, + createDeltaCoalescer, }; diff --git a/internal/js/chat-stream/vercel_stream_impl.js b/internal/js/chat-stream/vercel_stream_impl.js index 3f34fb6..02af872 100644 --- a/internal/js/chat-stream/vercel_stream_impl.js +++ b/internal/js/chat-stream/vercel_stream_impl.js @@ -20,7 +20,7 @@ const { boolDefaultTrue, resetStreamToolCallState, } = require('./toolcall_policy'); -const { createChatCompletionEmitter } = require('./stream_emitter'); +const { createChatCompletionEmitter, createDeltaCoalescer } = require('./stream_emitter'); const { asString, isAbortError, @@ -191,6 +191,7 @@ async function handleVercelStream(req, res, rawBody, payload) { model, isClosed: () => clientClosed, }); + const deltaCoalescer = createDeltaCoalescer({ sendDeltaFrame }); const finish = async (reason, options = {}) => { if (ended) { @@ -201,6 +202,7 @@ async function handleVercelStream(req, res, rawBody, payload) { await releaseLease(); return true; } + deltaCoalescer.flush(); const detected = parseStandaloneToolCalls(outputText, toolNames); if (detected.length > 0 && !toolCallsDoneEmitted) { toolCallsEmitted = true; @@ -210,6 +212,7 @@ async function handleVercelStream(req, res, rawBody, payload) { const tailEvents = flushToolSieve(toolSieveState, toolNames); for (const evt of tailEvents) { if (evt.type === 'tool_calls' && Array.isArray(evt.calls) && evt.calls.length > 0) { + deltaCoalescer.flush(); toolCallsEmitted = true; toolCallsDoneEmitted = true; sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs, payload.tools) }); @@ -217,9 +220,10 @@ async function handleVercelStream(req, res, rawBody, payload) { continue; } if (evt.text) { - sendDeltaFrame({ content: evt.text }); + deltaCoalescer.append('content', evt.text); } } + deltaCoalescer.flush(); } if (detected.length > 0 || toolCallsEmitted) { reason = 'tool_calls'; @@ -327,7 +331,7 @@ async function handleVercelStream(req, res, rawBody, payload) { continue; } thinkingText += trimmed; - sendDeltaFrame({ reasoning_content: trimmed }); + deltaCoalescer.append('reasoning_content', trimmed); } } else { const trimmed = trimContinuationOverlap(outputText, p.text); @@ -339,7 +343,7 @@ async function handleVercelStream(req, res, rawBody, payload) { } outputText += trimmed; if (!toolSieveEnabled) { - sendDeltaFrame({ content: trimmed }); + deltaCoalescer.append('content', trimmed); continue; } const events = processToolSieveChunk(toolSieveState, trimmed, toolNames); @@ -352,19 +356,21 @@ async function handleVercelStream(req, res, rawBody, payload) { const formatted = formatIncrementalToolCallDeltas(filtered, streamToolCallIDs); if (formatted.length > 0) { toolCallsEmitted = true; - sendDeltaFrame({ tool_calls: formatted }); + deltaCoalescer.flush(); + sendDeltaFrame({ tool_calls: formatted }); } continue; } if (evt.type === 'tool_calls') { toolCallsEmitted = true; toolCallsDoneEmitted = true; + deltaCoalescer.flush(); sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs, payload.tools) }); resetStreamToolCallState(streamToolCallIDs, streamToolNames); continue; } if (evt.text) { - sendDeltaFrame({ content: evt.text }); + deltaCoalescer.append('content', evt.text); } } } @@ -510,32 +516,51 @@ function observeContinueState(state, chunk) { if (topID > 0) { state.responseMessageID = topID; } - if (chunk.p === 'response/status') { - setContinueStatus(state, asString(chunk.v)); - } + observeContinueDirectPatch(state, chunk.p, chunk.v); if (chunk.p === 'response') { observeContinueBatchPatches(state, 'response', chunk.v); } else { observeContinueBatchPatches(state, '', chunk.v); } const response = chunk.v && typeof chunk.v === 'object' ? chunk.v.response : null; - if (response && typeof response === 'object') { - const id = numberValue(response.message_id); - if (id > 0) { - state.responseMessageID = id; - } - setContinueStatus(state, asString(response.status)); - if (response.auto_continue === true) { - state.lastStatus = 'AUTO_CONTINUE'; - } - } + observeContinueResponseObject(state, response); const messageResponse = chunk.message && typeof chunk.message === 'object' && chunk.message.response; - if (messageResponse && typeof messageResponse === 'object') { - const id = numberValue(messageResponse.message_id); - if (id > 0) { - state.responseMessageID = id; - } - setContinueStatus(state, asString(messageResponse.status)); + observeContinueResponseObject(state, messageResponse); +} + +function observeContinueDirectPatch(state, path, value) { + if (!state) { + return; + } + switch (asString(path).trim().replace(/^\/+|\/+$/g, '')) { + case 'response/status': + case 'status': + case 'response/quasi_status': + case 'quasi_status': + setContinueStatus(state, asString(value)); + break; + case 'response/auto_continue': + case 'auto_continue': + if (value === true) { + state.lastStatus = 'AUTO_CONTINUE'; + } + break; + default: + break; + } +} + +function observeContinueResponseObject(state, response) { + if (!state || !response || typeof response !== 'object') { + return; + } + const id = numberValue(response.message_id); + if (id > 0) { + state.responseMessageID = id; + } + setContinueStatus(state, asString(response.status)); + if (response.auto_continue === true) { + state.lastStatus = 'AUTO_CONTINUE'; } } @@ -563,6 +588,12 @@ function observeContinueBatchPatches(state, parentPath, raw) { case 'quasi_status': setContinueStatus(state, asString(patch.v)); break; + case 'response/auto_continue': + case 'auto_continue': + if (patch.v === true) { + state.lastStatus = 'AUTO_CONTINUE'; + } + break; default: break; } diff --git a/internal/js/helpers/stream-tool-sieve/parse_payload.js b/internal/js/helpers/stream-tool-sieve/parse_payload.js index 090cc77..40911bd 100644 --- a/internal/js/helpers/stream-tool-sieve/parse_payload.js +++ b/internal/js/helpers/stream-tool-sieve/parse_payload.js @@ -248,6 +248,9 @@ function replaceDSMLToolMarkupOutsideIgnored(text) { if (tag) { if (tag.dsmlLike) { out += `<${tag.closing ? '/' : ''}${tag.name}${raw.slice(tag.nameEnd, tag.end + 1)}`; + if (raw[tag.end] !== '>') { + out += '>'; + } } else { out += raw.slice(tag.start, tag.end + 1); } @@ -424,31 +427,42 @@ function scanToolMarkupTagAt(text, start) { } const lower = raw.toLowerCase(); let i = start + 1; + while (i < raw.length && raw[i] === '<') { + i += 1; + } const closing = raw[i] === '/'; if (closing) { i += 1; } - let dsmlLike = false; - if (i < raw.length && isToolMarkupPipe(raw[i])) { - dsmlLike = true; - i += 1; - } - if (lower.startsWith('dsml', i)) { - dsmlLike = true; - i += 'dsml'.length; - while (i < raw.length && isToolMarkupSeparator(raw[i])) { - i += 1; - } - } + const prefix = consumeToolMarkupNamePrefix(raw, lower, i); + i = prefix.next; + const dsmlLike = prefix.dsmlLike; const { name, len } = matchToolMarkupName(lower, i); if (!name) { return null; } - const nameEnd = i + len; + const originalNameEnd = i + len; + let nameEnd = originalNameEnd; + while (nameEnd < raw.length && isToolMarkupPipe(raw[nameEnd])) { + nameEnd += 1; + } + const hasTrailingPipe = nameEnd > originalNameEnd; if (!hasXmlTagBoundary(raw, nameEnd)) { return null; } - const end = findXmlTagEnd(raw, nameEnd); + let end = findXmlTagEnd(raw, nameEnd); + if (end < 0) { + if (!hasTrailingPipe) { + return null; + } + end = nameEnd - 1; + } + if (hasTrailingPipe) { + const nextLT = raw.indexOf('<', nameEnd); + if (nextLT >= 0 && end >= nextLT) { + end = nameEnd - 1; + } + } if (end < 0) { return null; } @@ -520,37 +534,94 @@ function findPartialToolMarkupStart(text) { if (lastLT < 0) { return -1; } - const tail = raw.slice(lastLT); + const start = includeDuplicateLeadingLessThan(raw, lastLT); + const tail = raw.slice(start); if (tail.includes('>')) { return -1; } - const lowerTail = tail.toLowerCase(); - const candidates = [ - ' 0 && text[out - 1] === '<') { + out -= 1; } - return -1; + return out; } function isToolMarkupPipe(ch) { return ch === '|' || ch === '|'; } -function isToolMarkupSeparator(ch) { - return ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n' || isToolMarkupPipe(ch); +function isPartialToolMarkupTagPrefix(text) { + const raw = toStringSafe(text); + if (!raw || raw[0] !== '<' || raw.includes('>')) { + return false; + } + const lower = raw.toLowerCase(); + let i = 1; + while (i < raw.length && raw[i] === '<') { + i += 1; + } + if (i >= raw.length) { + return true; + } + if (raw[i] === '/') { + i += 1; + } + while (i <= raw.length) { + if (i === raw.length) { + return true; + } + if (hasToolMarkupNamePrefix(lower.slice(i))) { + return true; + } + if ('dsml'.startsWith(lower.slice(i))) { + return true; + } + const next = consumeToolMarkupNamePrefixOnce(raw, lower, i); + if (!next.ok) { + return false; + } + i = next.next; + } + return false; +} + +function consumeToolMarkupNamePrefix(raw, lower, idx) { + let next = idx; + let dsmlLike = false; + while (true) { + const consumed = consumeToolMarkupNamePrefixOnce(raw, lower, next); + if (!consumed.ok) { + return { next, dsmlLike }; + } + next = consumed.next; + dsmlLike = true; + } +} + +function consumeToolMarkupNamePrefixOnce(raw, lower, idx) { + if (idx < raw.length && isToolMarkupPipe(raw[idx])) { + return { next: idx + 1, ok: true }; + } + if (idx < raw.length && [' ', '\t', '\r', '\n'].includes(raw[idx])) { + return { next: idx + 1, ok: true }; + } + if (lower.startsWith('dsml', idx)) { + return { next: idx + 'dsml'.length, ok: true }; + } + return { next: idx, ok: false }; +} + +function hasToolMarkupNamePrefix(lowerTail) { + for (const name of TOOL_MARKUP_NAMES) { + if (lowerTail.startsWith(name) || name.startsWith(lowerTail)) { + return true; + } + } + return false; } function matchToolMarkupName(lower, start) { diff --git a/internal/js/helpers/stream-tool-sieve/tool-keywords.js b/internal/js/helpers/stream-tool-sieve/tool-keywords.js deleted file mode 100644 index ac47e4e..0000000 --- a/internal/js/helpers/stream-tool-sieve/tool-keywords.js +++ /dev/null @@ -1,55 +0,0 @@ -'use strict'; - -const XML_TOOL_SEGMENT_TAGS = [ - '<|dsml|tool_calls>', '<|dsml|tool_calls\n', '<|dsml|tool_calls ', - '<|dsml|tool_calls>', '<|dsml|tool_calls\n', '<|dsml|tool_calls ', - '<|dsml|invoke ', '<|dsml|invoke\n', '<|dsml|invoke\t', '<|dsml|invoke\r', - '<|dsmltool_calls>', '<|dsmltool_calls\n', '<|dsmltool_calls ', - '<|dsmlinvoke ', '<|dsmlinvoke\n', '<|dsmlinvoke\t', '<|dsmlinvoke\r', - '<|dsml tool_calls>', '<|dsml tool_calls\n', '<|dsml tool_calls ', - '<|dsml invoke ', '<|dsml invoke\n', '<|dsml invoke\t', '<|dsml invoke\r', - '', '', '', '', '<|tool_calls\n', '<|tool_calls ', - '<|invoke ', '<|invoke\n', '<|invoke\t', '<|invoke\r', - '<|tool_calls>', '<|tool_calls\n', '<|tool_calls ', - '<|invoke ', '<|invoke\n', '<|invoke\t', '<|invoke\r', - '', '', - '', - '', - '', - '', - '', - '', - '', - '', - '', -]; - -module.exports = { - XML_TOOL_SEGMENT_TAGS, - XML_TOOL_OPENING_TAGS, - XML_TOOL_CLOSING_TAGS, -}; diff --git a/internal/promptcompat/prompt_build_test.go b/internal/promptcompat/prompt_build_test.go index b649fea..28da8e0 100644 --- a/internal/promptcompat/prompt_build_test.go +++ b/internal/promptcompat/prompt_build_test.go @@ -88,6 +88,58 @@ func TestBuildOpenAIFinalPrompt_VercelPreparePathKeepsFinalAnswerInstruction(t * } } +func TestBuildOpenAIFinalPromptReadLikeToolIncludesCacheGuard(t *testing.T) { + messages := []any{ + map[string]any{"role": "user", "content": "请读取文件"}, + } + tools := []any{ + map[string]any{ + "type": "function", + "function": map[string]any{ + "name": "read_file", + "description": "Read a file", + "parameters": map[string]any{ + "type": "object", + }, + }, + }, + } + + finalPrompt, _ := buildOpenAIFinalPrompt(messages, tools, "", false) + if !strings.Contains(finalPrompt, "Read-tool cache guard") { + t.Fatalf("read-like tool prompt missing cache guard: %q", finalPrompt) + } + if !strings.Contains(finalPrompt, "provides no file body") { + t.Fatalf("read-like tool prompt missing no-body handling: %q", finalPrompt) + } + if !strings.Contains(finalPrompt, "Do not repeatedly call the same read request") { + t.Fatalf("read-like tool prompt missing loop guard: %q", finalPrompt) + } +} + +func TestBuildOpenAIFinalPromptNonReadToolOmitsCacheGuard(t *testing.T) { + messages := []any{ + map[string]any{"role": "user", "content": "搜索一下"}, + } + tools := []any{ + map[string]any{ + "type": "function", + "function": map[string]any{ + "name": "search", + "description": "Search docs", + "parameters": map[string]any{ + "type": "object", + }, + }, + }, + } + + finalPrompt, _ := buildOpenAIFinalPrompt(messages, tools, "", false) + if strings.Contains(finalPrompt, "Read-tool cache guard") { + t.Fatalf("non-read tool prompt should not include read cache guard: %q", finalPrompt) + } +} + func TestBuildOpenAIFinalPromptWithThinkingKeepsPromptUnchanged(t *testing.T) { messages := []any{ map[string]any{"role": "user", "content": "继续回答上一个问题"}, diff --git a/internal/promptcompat/request_normalize.go b/internal/promptcompat/request_normalize.go index 8efa772..833a54e 100644 --- a/internal/promptcompat/request_normalize.go +++ b/internal/promptcompat/request_normalize.go @@ -39,20 +39,22 @@ func NormalizeOpenAIChatRequest(store ConfigReader, req map[string]any, traceID refFileIDs := CollectOpenAIRefFileIDs(req) return StandardRequest{ - Surface: "openai_chat", - RequestedModel: strings.TrimSpace(model), - ResolvedModel: resolvedModel, - ResponseModel: responseModel, - Messages: messagesRaw, - ToolsRaw: req["tools"], - FinalPrompt: finalPrompt, - ToolNames: toolNames, - ToolChoice: toolPolicy, - Stream: util.ToBool(req["stream"]), - Thinking: thinkingEnabled, - Search: searchEnabled, - RefFileIDs: refFileIDs, - PassThrough: passThrough, + Surface: "openai_chat", + RequestedModel: strings.TrimSpace(model), + ResolvedModel: resolvedModel, + ResponseModel: responseModel, + Messages: messagesRaw, + PromptTokenText: finalPrompt, + ToolsRaw: req["tools"], + FinalPrompt: finalPrompt, + ToolNames: toolNames, + ToolChoice: toolPolicy, + Stream: util.ToBool(req["stream"]), + Thinking: thinkingEnabled, + Search: searchEnabled, + RefFileIDs: refFileIDs, + RefFileTokens: estimateInlineFileTokens(req), + PassThrough: passThrough, }, nil } @@ -99,20 +101,22 @@ func NormalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra refFileIDs := CollectOpenAIRefFileIDs(req) return StandardRequest{ - Surface: "openai_responses", - RequestedModel: model, - ResolvedModel: resolvedModel, - ResponseModel: model, - Messages: messagesRaw, - ToolsRaw: req["tools"], - FinalPrompt: finalPrompt, - ToolNames: toolNames, - ToolChoice: toolPolicy, - Stream: util.ToBool(req["stream"]), - Thinking: thinkingEnabled, - Search: searchEnabled, - RefFileIDs: refFileIDs, - PassThrough: passThrough, + Surface: "openai_responses", + RequestedModel: model, + ResolvedModel: resolvedModel, + ResponseModel: model, + Messages: messagesRaw, + PromptTokenText: finalPrompt, + ToolsRaw: req["tools"], + FinalPrompt: finalPrompt, + ToolNames: toolNames, + ToolChoice: toolPolicy, + Stream: util.ToBool(req["stream"]), + Thinking: thinkingEnabled, + Search: searchEnabled, + RefFileIDs: refFileIDs, + RefFileTokens: estimateInlineFileTokens(req), + PassThrough: passThrough, }, nil } @@ -356,3 +360,30 @@ func namesToSet(names []string) map[string]struct{} { } return out } + +// estimateInlineFileTokens extracts the byte count stashed by PreprocessInlineFileInputs +// and converts it to a conservative token estimate. Inline files are typically images or +// documents that the upstream model will process; we use bytes/3 (rather than bytes/4) +// as a slightly pessimistic approximation so the returned context token count stays +// safely above the real value. +func estimateInlineFileTokens(req map[string]any) int { + raw, ok := req["_inline_file_bytes"] + if !ok { + return 0 + } + var bytes int + switch v := raw.(type) { + case int: + bytes = v + case int64: + bytes = int(v) + case float64: + bytes = int(v) + default: + return 0 + } + if bytes <= 0 { + return 0 + } + return bytes / 3 +} diff --git a/internal/promptcompat/standard_request.go b/internal/promptcompat/standard_request.go index 6480d9b..76b812d 100644 --- a/internal/promptcompat/standard_request.go +++ b/internal/promptcompat/standard_request.go @@ -9,6 +9,7 @@ type StandardRequest struct { ResponseModel string Messages []any HistoryText string + PromptTokenText string CurrentInputFileApplied bool ToolsRaw any FinalPrompt string @@ -18,6 +19,7 @@ type StandardRequest struct { Thinking bool Search bool RefFileIDs []string + RefFileTokens int PassThrough map[string]any } diff --git a/internal/promptcompat/tool_prompt.go b/internal/promptcompat/tool_prompt.go index 95d2f8b..4e5d03f 100644 --- a/internal/promptcompat/tool_prompt.go +++ b/internal/promptcompat/tool_prompt.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "strings" + "unicode" "ds2api/internal/toolcall" ) @@ -46,6 +47,9 @@ func injectToolPrompt(messages []map[string]any, tools []any, policy ToolChoiceP return messages, names } toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\n" + toolcall.BuildToolCallInstructions(names) + if hasReadLikeTool(names) { + toolPrompt += "\n\nRead-tool cache guard: If a Read/read_file-style tool result says the file is unchanged, already available in history, should be referenced from previous context, or otherwise provides no file body, treat that result as missing content. Do not repeatedly call the same read request for that missing body. Request a full-content read if the tool supports it, or tell the user that the file contents need to be provided again." + } if policy.Mode == ToolChoiceRequired { toolPrompt += "\n7) For this response, you MUST call at least one tool from the allowed list." } @@ -64,3 +68,23 @@ func injectToolPrompt(messages []map[string]any, tools []any, policy ToolChoiceP messages = append([]map[string]any{{"role": "system", "content": toolPrompt}}, messages...) return messages, names } + +func hasReadLikeTool(names []string) bool { + for _, name := range names { + switch normalizeToolNameForGuard(name) { + case "read", "readfile": + return true + } + } + return false +} + +func normalizeToolNameForGuard(name string) string { + var b strings.Builder + for _, r := range strings.ToLower(strings.TrimSpace(name)) { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + } + } + return b.String() +} diff --git a/internal/server/router.go b/internal/server/router.go index 60db26b..ea13e69 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -98,6 +98,14 @@ func NewApp() (*App, error) { r.Get("/v1/responses/{response_id}", responsesHandler.GetResponseByID) r.Post("/v1/files", filesHandler.UploadFile) r.Post("/v1/embeddings", embeddingsHandler.Embeddings) + // Root OpenAI aliases support clients configured with the bare DS2API service URL. + r.Get("/models", modelsHandler.ListModels) + r.Get("/models/{model_id}", modelsHandler.GetModel) + r.Post("/chat/completions", chatHandler.ChatCompletions) + r.Post("/responses", responsesHandler.Responses) + r.Get("/responses/{response_id}", responsesHandler.GetResponseByID) + r.Post("/files", filesHandler.UploadFile) + r.Post("/embeddings", embeddingsHandler.Embeddings) claude.RegisterRoutes(r, claudeHandler) gemini.RegisterRoutes(r, geminiHandler) r.Route("/admin", func(ar chi.Router) { diff --git a/internal/server/router_routes_test.go b/internal/server/router_routes_test.go index 3891c8d..edb44e0 100644 --- a/internal/server/router_routes_test.go +++ b/internal/server/router_routes_test.go @@ -37,6 +37,13 @@ func TestAPIRoutesRemainRegistered(t *testing.T) { "GET /v1/responses/{response_id}", "POST /v1/files", "POST /v1/embeddings", + "GET /models", + "GET /models/{model_id}", + "POST /chat/completions", + "POST /responses", + "GET /responses/{response_id}", + "POST /files", + "POST /embeddings", "GET /anthropic/v1/models", "POST /anthropic/v1/messages", "POST /anthropic/v1/messages/count_tokens", diff --git a/internal/sse/consumer_edge_test.go b/internal/sse/consumer_edge_test.go index 4654ef8..8d39a3f 100644 --- a/internal/sse/consumer_edge_test.go +++ b/internal/sse/consumer_edge_test.go @@ -41,6 +41,15 @@ func TestCollectStreamTextOnly(t *testing.T) { } } +func TestCollectStreamHandlesLongSingleSSELine(t *testing.T) { + payload := strings.Repeat("x", 2*1024*1024+4096) + resp := makeHTTPResponse(makeLargeContentSSEBody(t, payload)) + result := CollectStream(resp, false, true) + if result.Text != payload { + t.Fatalf("long SSE line payload mismatch: got len=%d want len=%d", len(result.Text), len(payload)) + } +} + func TestCollectStreamThinkingAndText(t *testing.T) { resp := makeHTTPResponse( "data: {\"p\":\"response/thinking_content\",\"v\":\"Thinking...\"}\n" + diff --git a/internal/sse/parser.go b/internal/sse/parser.go index abb2eb6..528bd2f 100644 --- a/internal/sse/parser.go +++ b/internal/sse/parser.go @@ -92,6 +92,7 @@ func ParseSSEChunkForContentDetailed(chunk map[string]any, thinkingEnabled bool, } newType := currentFragmentType parts := make([]ContentPart, 0, 8) + updateTypeFromExplicitPath(path, thinkingEnabled, &newType) collectDirectFragments(path, chunk, v, &newType, &parts) updateTypeFromNestedResponse(path, v, &newType) partType := resolvePartType(path, thinkingEnabled, newType) @@ -107,11 +108,24 @@ func ParseSSEChunkForContentDetailed(chunk map[string]any, thinkingEnabled bool, detectionThinkingParts := selectThinkingParts(parts) if !thinkingEnabled { parts = dropThinkingParts(parts) - newType = "text" } return parts, detectionThinkingParts, false, newType } +func updateTypeFromExplicitPath(path string, thinkingEnabled bool, newType *string) { + if newType == nil { + return + } + switch path { + case "response/content": + *newType = "text" + case "response/thinking_content": + if !thinkingEnabled || *newType != "text" { + *newType = "thinking" + } + } +} + func selectThinkingParts(parts []ContentPart) []ContentPart { if len(parts) == 0 { return nil @@ -206,8 +220,11 @@ func resolvePartType(path string, thinkingEnabled bool, newType string) string { return "text" case strings.Contains(path, "response/fragments") && strings.Contains(path, "/content"): return newType - case path == "" && thinkingEnabled: - return newType + case path == "": + if newType != "" { + return newType + } + return "text" default: return "text" } @@ -244,11 +261,29 @@ func appendChunkValueContent(v any, partType string, newType *string, parts *[]C } *parts = append(*parts, pp...) case map[string]any: + if appendObjectContentByPath(path, val, partType, parts) { + return false + } appendWrappedFragments(val, partType, newType, parts) } return false } +func appendObjectContentByPath(path string, val map[string]any, partType string, parts *[]ContentPart) bool { + if path != "response/content" && path != "response/thinking_content" && path != "" { + return false + } + text, _ := val["text"].(string) + if text == "" { + text, _ = val["content"].(string) + } + if text == "" { + return false + } + appendContentPart(parts, text, partType) + return true +} + func appendWrappedFragments(val map[string]any, partType string, newType *string, parts *[]ContentPart) { resp := val if wrapped, ok := val["response"].(map[string]any); ok { diff --git a/internal/sse/parser_test.go b/internal/sse/parser_test.go index 6ea69fa..16c02b9 100644 --- a/internal/sse/parser_test.go +++ b/internal/sse/parser_test.go @@ -88,6 +88,71 @@ func TestParseSSEChunkForContentAfterAppendUsesUpdatedType(t *testing.T) { } } +func TestParseSSEChunkForContentThinkingDisabledKeepsHiddenFragmentState(t *testing.T) { + chunk1 := map[string]any{ + "p": "response/fragments", + "o": "APPEND", + "v": []any{ + map[string]any{"type": "THINK", "content": "我们"}, + }, + } + parts1, finished1, nextType1 := ParseSSEChunkForContent(chunk1, false, "text") + if finished1 { + t.Fatal("expected first chunk unfinished") + } + if nextType1 != "thinking" { + t.Fatalf("expected hidden THINK fragment to keep next type thinking, got %q", nextType1) + } + if len(parts1) != 0 { + t.Fatalf("expected hidden thinking to be dropped, got %#v", parts1) + } + + chunk2 := map[string]any{ + "p": "response/fragments/-1/content", + "v": "被", + } + parts2, finished2, nextType2 := ParseSSEChunkForContent(chunk2, false, nextType1) + if finished2 { + t.Fatal("expected second chunk unfinished") + } + if nextType2 != "thinking" { + t.Fatalf("expected hidden continuation to keep next type thinking, got %q", nextType2) + } + if len(parts2) != 0 { + t.Fatalf("expected hidden continuation to be dropped, got %#v", parts2) + } + + chunk3 := map[string]any{"v": "要求"} + parts3, finished3, nextType3 := ParseSSEChunkForContent(chunk3, false, nextType2) + if finished3 { + t.Fatal("expected third chunk unfinished") + } + if nextType3 != "thinking" { + t.Fatalf("expected pathless hidden continuation to keep next type thinking, got %q", nextType3) + } + if len(parts3) != 0 { + t.Fatalf("expected pathless hidden continuation to be dropped, got %#v", parts3) + } + + chunk4 := map[string]any{ + "p": "response/fragments", + "o": "APPEND", + "v": []any{ + map[string]any{"type": "RESPONSE", "content": "答"}, + }, + } + parts4, finished4, nextType4 := ParseSSEChunkForContent(chunk4, false, nextType3) + if finished4 { + t.Fatal("expected fourth chunk unfinished") + } + if nextType4 != "text" { + t.Fatalf("expected RESPONSE fragment to switch next type text, got %q", nextType4) + } + if len(parts4) != 1 || parts4[0].Type != "text" || parts4[0].Text != "答" { + t.Fatalf("expected visible response text, got %#v", parts4) + } +} + func TestParseSSEChunkForContentAutoTransitionsThinkClose(t *testing.T) { chunk := map[string]any{ "p": "response/thinking_content", @@ -163,3 +228,44 @@ func TestParseSSEChunkForContentStripsLeakedThinkTagsFromText(t *testing.T) { t.Fatalf("expected leaked think tag to be stripped, got %#v", parts[0]) } } + +func TestParseSSEChunkForContentResponseContentObjectShape(t *testing.T) { + chunk := map[string]any{ + "p": "response/content", + "v": map[string]any{"text": "对象内容"}, + } + parts, finished, _ := ParseSSEChunkForContent(chunk, false, "text") + if finished { + t.Fatal("expected unfinished") + } + if len(parts) != 1 || parts[0].Text != "对象内容" || parts[0].Type != "text" { + t.Fatalf("unexpected parts: %#v", parts) + } +} + +func TestParseSSEChunkForThinkingContentObjectShape(t *testing.T) { + chunk := map[string]any{ + "p": "response/thinking_content", + "v": map[string]any{"content": "对象思考"}, + } + parts, finished, _ := ParseSSEChunkForContent(chunk, true, "thinking") + if finished { + t.Fatal("expected unfinished") + } + if len(parts) != 1 || parts[0].Text != "对象思考" || parts[0].Type != "thinking" { + t.Fatalf("unexpected parts: %#v", parts) + } +} + +func TestParseSSEChunkForContentObjectShapeWithoutPath(t *testing.T) { + chunk := map[string]any{ + "v": map[string]any{"text": "无路径对象内容"}, + } + parts, finished, _ := ParseSSEChunkForContent(chunk, false, "text") + if finished { + t.Fatal("expected unfinished") + } + if len(parts) != 1 || parts[0].Text != "无路径对象内容" || parts[0].Type != "text" { + t.Fatalf("unexpected parts: %#v", parts) + } +} diff --git a/internal/sse/stream.go b/internal/sse/stream.go index 8b8aa9b..44f75cb 100644 --- a/internal/sse/stream.go +++ b/internal/sse/stream.go @@ -9,8 +9,7 @@ import ( const ( parsedLineBufferSize = 128 - scannerBufferSize = 64 * 1024 - maxScannerLineSize = 2 * 1024 * 1024 + lineReaderBufferSize = 64 * 1024 minFlushChars = 160 maxFlushWait = 80 * time.Millisecond ) @@ -29,8 +28,8 @@ func StartParsedLinePump(ctx context.Context, body io.Reader, thinkingEnabled bo eof bool } lineCh := make(chan scanItem, 1) - stopScanner := make(chan struct{}) - defer close(stopScanner) + stopReader := make(chan struct{}) + defer close(stopReader) go func() { sendScanItem := func(item scanItem) bool { select { @@ -38,20 +37,28 @@ func StartParsedLinePump(ctx context.Context, body io.Reader, thinkingEnabled bo return true case <-ctx.Done(): return false - case <-stopScanner: + case <-stopReader: return false } } defer close(lineCh) - scanner := bufio.NewScanner(body) - scanner.Buffer(make([]byte, 0, scannerBufferSize), maxScannerLineSize) - for scanner.Scan() { - line := append([]byte{}, scanner.Bytes()...) - if !sendScanItem(scanItem{line: line}) { + reader := bufio.NewReaderSize(body, lineReaderBufferSize) + for { + line, err := reader.ReadBytes('\n') + if len(line) > 0 { + line = append([]byte{}, line...) + if !sendScanItem(scanItem{line: line}) { + return + } + } + if err != nil { + if err == io.EOF { + err = nil + } + _ = sendScanItem(scanItem{err: err, eof: true}) return } } - _ = sendScanItem(scanItem{err: scanner.Err(), eof: true}) }() ticker := time.NewTicker(maxFlushWait) diff --git a/internal/sse/stream_edge_test.go b/internal/sse/stream_edge_test.go index 7c5ea4c..40b4460 100644 --- a/internal/sse/stream_edge_test.go +++ b/internal/sse/stream_edge_test.go @@ -158,11 +158,13 @@ func TestStartParsedLinePumpNonSSELines(t *testing.T) { func TestStartParsedLinePumpThinkingDisabled(t *testing.T) { body := strings.NewReader( - "data: {\"p\":\"response/thinking_content\",\"v\":\"thought\"}\n" + + "data: {\"p\":\"response/fragments\",\"o\":\"APPEND\",\"v\":[{\"type\":\"THINK\",\"content\":\"思\"}]}\n" + + "data: {\"p\":\"response/fragments/-1/content\",\"v\":\"考\"}\n" + + "data: {\"v\":\"隐藏\"}\n" + + "data: {\"p\":\"response/fragments\",\"o\":\"APPEND\",\"v\":[{\"type\":\"RESPONSE\",\"content\":\"答\"}]}\n" + "data: {\"p\":\"response/content\",\"v\":\"response\"}\n" + "data: [DONE]\n", ) - // With thinking disabled, thinking content should still be emitted but marked differently results, done := StartParsedLinePump(context.Background(), body, false, "text") var parts []ContentPart @@ -171,8 +173,15 @@ func TestStartParsedLinePumpThinkingDisabled(t *testing.T) { } <-done - if len(parts) < 1 { - t.Fatalf("expected at least 1 part, got %d", len(parts)) + got := strings.Builder{} + for _, p := range parts { + if p.Type != "text" { + t.Fatalf("expected only text parts with thinking disabled, got %#v", parts) + } + got.WriteString(p.Text) + } + if got.String() != "答response" { + t.Fatalf("expected hidden thinking to be dropped, got %q from %#v", got.String(), parts) } } diff --git a/internal/sse/stream_test.go b/internal/sse/stream_test.go index a4fd2bb..d6addb7 100644 --- a/internal/sse/stream_test.go +++ b/internal/sse/stream_test.go @@ -2,10 +2,23 @@ package sse import ( "context" + "encoding/json" "strings" "testing" ) +func makeLargeContentSSEBody(t *testing.T, payload string) string { + t.Helper() + line, err := json.Marshal(map[string]any{ + "p": "response/content", + "v": payload, + }) + if err != nil { + t.Fatalf("marshal SSE line failed: %v", err) + } + return "data: " + string(line) + "\n" + "data: [DONE]\n" +} + func TestStartParsedLinePumpParsesAndStops(t *testing.T) { body := strings.NewReader("data: {\"p\":\"response/content\",\"v\":\"hi\"}\n\ndata: [DONE]\n") results, done := StartParsedLinePump(context.Background(), body, false, "text") @@ -28,3 +41,28 @@ func TestStartParsedLinePumpParsesAndStops(t *testing.T) { t.Fatalf("expected last line to stop stream, got parsed=%v stop=%v", last.Parsed, last.Stop) } } + +func TestStartParsedLinePumpHandlesLongSingleSSELine(t *testing.T) { + payload := strings.Repeat("x", 2*1024*1024+4096) + results, done := StartParsedLinePump(context.Background(), strings.NewReader(makeLargeContentSSEBody(t, payload)), false, "text") + + var got strings.Builder + var sawDone bool + for r := range results { + for _, p := range r.Parts { + got.WriteString(p.Text) + } + if r.Stop { + sawDone = true + } + } + if err := <-done; err != nil { + t.Fatalf("unexpected long-line read error: %v", err) + } + if got.String() != payload { + t.Fatalf("long SSE line payload mismatch: got len=%d want len=%d", got.Len(), len(payload)) + } + if !sawDone { + t.Fatal("expected DONE after long SSE line") + } +} diff --git a/internal/toolcall/toolcalls_dsml.go b/internal/toolcall/toolcalls_dsml.go index c93e04c..c75702f 100644 --- a/internal/toolcall/toolcalls_dsml.go +++ b/internal/toolcall/toolcalls_dsml.go @@ -44,6 +44,9 @@ func rewriteDSMLToolMarkupOutsideIgnored(text string) string { } b.WriteString(tag.Name) b.WriteString(text[tag.NameEnd : tag.End+1]) + if text[tag.End] != '>' { + b.WriteByte('>') + } i = tag.End + 1 continue } diff --git a/internal/toolcall/toolcalls_scan.go b/internal/toolcall/toolcalls_scan.go index 099f73b..f1d3c4a 100644 --- a/internal/toolcall/toolcalls_scan.go +++ b/internal/toolcall/toolcalls_scan.go @@ -128,34 +128,39 @@ func scanToolMarkupTagAt(text string, start int) (ToolMarkupTag, bool) { } lower := strings.ToLower(text) i := start + 1 + for i < len(text) && text[i] == '<' { + i++ + } closing := false if i < len(text) && text[i] == '/' { closing = true i++ } - dsmlLike := false - if next, ok := consumeToolMarkupPipe(text, i); ok { - dsmlLike = true - i = next - } - if strings.HasPrefix(lower[i:], "dsml") { - dsmlLike = true - i += len("dsml") - for next, ok := consumeToolMarkupSeparator(text, i); ok; next, ok = consumeToolMarkupSeparator(text, i) { - i = next - } - } + i, dsmlLike := consumeToolMarkupNamePrefix(lower, text, i) name, nameLen := matchToolMarkupName(lower, i) if nameLen == 0 { return ToolMarkupTag{}, false } nameEnd := i + nameLen + nameEndBeforePipes := nameEnd + for next, ok := consumeToolMarkupPipe(text, nameEnd); ok; next, ok = consumeToolMarkupPipe(text, nameEnd) { + nameEnd = next + } + hasTrailingPipe := nameEnd > nameEndBeforePipes if !hasToolMarkupBoundary(text, nameEnd) { return ToolMarkupTag{}, false } end := findXMLTagEnd(text, nameEnd) if end < 0 { - return ToolMarkupTag{}, false + if !hasTrailingPipe { + return ToolMarkupTag{}, false + } + end = nameEnd - 1 + } + if hasTrailingPipe { + if nextLT := strings.IndexByte(text[nameEnd:], '<'); nextLT >= 0 && end >= nameEnd+nextLT { + end = nameEnd - 1 + } } trimmed := strings.TrimSpace(text[start : end+1]) return ToolMarkupTag{ @@ -171,6 +176,74 @@ func scanToolMarkupTagAt(text string, start int) (ToolMarkupTag, bool) { }, true } +func IsPartialToolMarkupTagPrefix(text string) bool { + if text == "" || text[0] != '<' || strings.Contains(text, ">") { + return false + } + lower := strings.ToLower(text) + i := 1 + for i < len(text) && text[i] == '<' { + i++ + } + if i >= len(text) { + return true + } + if text[i] == '/' { + i++ + } + for i <= len(text) { + if i == len(text) { + return true + } + if hasToolMarkupNamePrefix(lower[i:]) { + return true + } + if strings.HasPrefix("dsml", lower[i:]) { + return true + } + next, ok := consumeToolMarkupNamePrefixOnce(lower, text, i) + if !ok { + return false + } + i = next + } + return false +} + +func consumeToolMarkupNamePrefix(lower, text string, idx int) (int, bool) { + dsmlLike := false + for { + next, ok := consumeToolMarkupNamePrefixOnce(lower, text, idx) + if !ok { + return idx, dsmlLike + } + idx = next + dsmlLike = true + } +} + +func consumeToolMarkupNamePrefixOnce(lower, text string, idx int) (int, bool) { + if next, ok := consumeToolMarkupPipe(text, idx); ok { + return next, true + } + if idx < len(text) && (text[idx] == ' ' || text[idx] == '\t' || text[idx] == '\r' || text[idx] == '\n') { + return idx + 1, true + } + if strings.HasPrefix(lower[idx:], "dsml") { + return idx + len("dsml"), true + } + return idx, false +} + +func hasToolMarkupNamePrefix(lowerTail string) bool { + for _, name := range toolMarkupNames { + if strings.HasPrefix(lowerTail, name) || strings.HasPrefix(name, lowerTail) { + return true + } + } + return false +} + func matchToolMarkupName(lower string, start int) (string, int) { for _, name := range toolMarkupNames { if strings.HasPrefix(lower[start:], name) { @@ -193,19 +266,6 @@ func consumeToolMarkupPipe(text string, idx int) (int, bool) { return idx, false } -func consumeToolMarkupSeparator(text string, idx int) (int, bool) { - if idx >= len(text) { - return idx, false - } - if text[idx] == ' ' || text[idx] == '\t' || text[idx] == '\r' || text[idx] == '\n' { - return idx + 1, true - } - if next, ok := consumeToolMarkupPipe(text, idx); ok { - return next, true - } - return idx, false -} - func hasToolMarkupBoundary(text string, idx int) bool { if idx >= len(text) { return true diff --git a/internal/toolcall/toolcalls_test.go b/internal/toolcall/toolcalls_test.go index b68955b..01de962 100644 --- a/internal/toolcall/toolcalls_test.go +++ b/internal/toolcall/toolcalls_test.go @@ -41,6 +41,52 @@ func TestParseToolCallsSupportsDSMLShell(t *testing.T) { } } +func TestParseToolCallsToleratesDSMLTrailingPipeTagTerminator(t *testing.T) { + text := strings.Join([]string{ + `<|DSML|tool_calls| `, + ` <|DSML|invoke name="terminal">`, + ` <|DSML|parameter name="command">`, + ` <|DSML|parameter name="timeout">`, + ` `, + ``, + }, "\n") + calls := ParseToolCalls(text, []string{"terminal"}) + if len(calls) != 1 { + t.Fatalf("expected one trailing-pipe DSML call, got %#v", calls) + } + if calls[0].Name != "terminal" { + t.Fatalf("expected terminal tool, got %#v", calls[0]) + } + if calls[0].Input["command"] != `find "/home" -type d` { + t.Fatalf("expected command argument, got %#v", calls[0].Input) + } + if calls[0].Input["timeout"] != float64(10) { + t.Fatalf("expected numeric timeout, got %#v", calls[0].Input) + } +} + +func TestParseToolCallsToleratesExtraLeadingLessThanBeforeDSML(t *testing.T) { + text := `<<|DSML|tool_calls><<|DSML|invoke name="Bash"><<|DSML|parameter name="command">` + calls := ParseToolCalls(text, []string{"Bash"}) + if len(calls) != 1 { + t.Fatalf("expected one extra-leading-less-than DSML call, got %#v", calls) + } + if calls[0].Name != "Bash" || calls[0].Input["command"] != "pwd" { + t.Fatalf("unexpected extra-leading-less-than DSML parse result: %#v", calls[0]) + } +} + +func TestParseToolCallsToleratesRepeatedDSMLPrefixNoise(t *testing.T) { + text := `<<<` + calls := ParseToolCalls(text, []string{"Bash"}) + if len(calls) != 1 { + t.Fatalf("expected one repeated-prefix DSML call, got %#v", calls) + } + if calls[0].Name != "Bash" || calls[0].Input["command"] != "git status" { + t.Fatalf("unexpected repeated-prefix DSML parse result: %#v", calls[0]) + } +} + func TestParseToolCallsSupportsDSMLShellWithCanonicalExampleInCDATA(t *testing.T) { content := `x` text := `<|DSML|tool_calls><|DSML|invoke name="Write"><|DSML|parameter name="file_path">notes.md<|DSML|parameter name="content">` diff --git a/internal/toolstream/tool_sieve_core.go b/internal/toolstream/tool_sieve_core.go index a228c13..afe7c7f 100644 --- a/internal/toolstream/tool_sieve_core.go +++ b/internal/toolstream/tool_sieve_core.go @@ -1,10 +1,6 @@ package toolstream -import ( - "strings" - - "ds2api/internal/toolcall" -) +import "ds2api/internal/toolcall" func ProcessChunk(state *State, chunk string, toolNames []string) []Event { if state == nil { @@ -174,31 +170,27 @@ func findToolSegmentStart(state *State, s string) int { if s == "" { return -1 } - lower := strings.ToLower(s) offset := 0 for { - bestKeyIdx := -1 - matchedTag := "" - for _, tag := range xmlToolTagsToDetect { - idx := strings.Index(lower[offset:], tag) - if idx >= 0 { - idx += offset - if bestKeyIdx < 0 || idx < bestKeyIdx { - bestKeyIdx = idx - matchedTag = tag - } - } - } - if bestKeyIdx < 0 { + tag, ok := toolcall.FindToolMarkupTagOutsideIgnored(s, offset) + if !ok { return -1 } - if !insideCodeFenceWithState(state, s[:bestKeyIdx]) { - return bestKeyIdx + start := includeDuplicateLeadingLessThan(s, tag.Start) + if !insideCodeFenceWithState(state, s[:start]) { + return start } - offset = bestKeyIdx + len(matchedTag) + offset = tag.End + 1 } } +func includeDuplicateLeadingLessThan(s string, idx int) int { + for idx > 0 && s[idx-1] == '<' { + idx-- + } + return idx +} + func consumeToolCapture(state *State, toolNames []string) (prefix string, calls []toolcall.ParsedToolCall, suffix string, ready bool) { captured := state.capture.String() if captured == "" { diff --git a/internal/toolstream/tool_sieve_xml.go b/internal/toolstream/tool_sieve_xml.go index f2a5718..8e728e3 100644 --- a/internal/toolstream/tool_sieve_xml.go +++ b/internal/toolstream/tool_sieve_xml.go @@ -153,27 +153,14 @@ func findPartialXMLToolTagStart(s string) int { if lastLT < 0 { return -1 } - tail := s[lastLT:] + start := includeDuplicateLeadingLessThan(s, lastLT) + tail := s[start:] // If there's a '>' in the tail, the tag is closed — not partial. if strings.Contains(tail, ">") { return -1 } - lowerTail := strings.ToLower(tail) - for _, tag := range []string{ - "", "", "", "", "", "", "", "", ""} - -// xmlToolCallBlockPattern matches a complete canonical XML tool call block. -// -//nolint:unused // reserved for future fast-path XML block detection. -var xmlToolCallBlockPattern = regexp.MustCompile(`(?is)((?:]*>\s*(?:.*?)\s*(?:|))`) - -// xmlToolTagsToDetect is the set of XML tag prefixes used by findToolSegmentStart. -var xmlToolTagsToDetect = []string{ - "<|dsml|tool_calls>", "<|dsml|tool_calls\n", "<|dsml|tool_calls ", - "<|dsml|tool_calls>", "<|dsml|tool_calls\n", "<|dsml|tool_calls ", - "<|dsml|invoke ", "<|dsml|invoke\n", "<|dsml|invoke\t", "<|dsml|invoke\r", - "<|dsmltool_calls>", "<|dsmltool_calls\n", "<|dsmltool_calls ", - "<|dsmlinvoke ", "<|dsmlinvoke\n", "<|dsmlinvoke\t", "<|dsmlinvoke\r", - "<|dsml tool_calls>", "<|dsml tool_calls\n", "<|dsml tool_calls ", - "<|dsml invoke ", "<|dsml invoke\n", "<|dsml invoke\t", "<|dsml invoke\r", - "", "", "", "", "<|tool_calls\n", "<|tool_calls ", - "<|invoke ", "<|invoke\n", "<|invoke\t", "<|invoke\r", - "<|tool_calls>", "<|tool_calls\n", "<|tool_calls ", - "<|invoke ", "<|invoke\n", "<|invoke\t", "<|invoke\r", - "", "` + "\n", + ` <|DSML|parameter name="command">` + "\n", + ` <|DSML|parameter name="timeout">` + "\n", + " \n", + "", + } + var events []Event + for _, c := range chunks { + events = append(events, ProcessChunk(&state, c, []string{"terminal"})...) + } + events = append(events, Flush(&state, []string{"terminal"})...) + + var textContent strings.Builder + var calls []any + for _, evt := range events { + textContent.WriteString(evt.Content) + for _, call := range evt.ToolCalls { + calls = append(calls, call) + } + } + if text := textContent.String(); strings.Contains(strings.ToLower(text), "dsml") || strings.Contains(text, "terminal") { + t.Fatalf("trailing-pipe DSML tool call leaked to text: %q events=%#v", text, events) + } + if len(calls) != 1 { + t.Fatalf("expected one trailing-pipe DSML tool call, got %d events=%#v", len(calls), events) + } +} + +func TestProcessToolSieveInterceptsExtraLeadingLessThanDSMLToolCallWithoutLeak(t *testing.T) { + var state State + chunks := []string{ + "<<|DSML|tool_calls>\n", + ` <<|DSML|invoke name="Bash">` + "\n", + ` <<|DSML|parameter name="command">` + "\n", + " \n", + "", + } + var events []Event + for _, c := range chunks { + events = append(events, ProcessChunk(&state, c, []string{"Bash"})...) + } + events = append(events, Flush(&state, []string{"Bash"})...) + + var textContent strings.Builder + toolCalls := 0 + for _, evt := range events { + textContent.WriteString(evt.Content) + toolCalls += len(evt.ToolCalls) + } + if text := textContent.String(); strings.Contains(text, "<") || strings.Contains(text, "Bash") { + t.Fatalf("extra-leading-less-than DSML tool call leaked to text: %q events=%#v", text, events) + } + if toolCalls != 1 { + t.Fatalf("expected one extra-leading-less-than DSML tool call, got %d events=%#v", toolCalls, events) + } +} + +func TestProcessToolSieveInterceptsRepeatedDSMLPrefixNoiseWithoutLeak(t *testing.T) { + var state State + chunks := []string{ + "<\n", + ` <` + "\n", + ` <` + "\n", + " \n", + "", + } + var events []Event + for _, c := range chunks { + events = append(events, ProcessChunk(&state, c, []string{"Bash"})...) + } + events = append(events, Flush(&state, []string{"Bash"})...) + + var textContent strings.Builder + toolCalls := 0 + for _, evt := range events { + textContent.WriteString(evt.Content) + toolCalls += len(evt.ToolCalls) + } + if text := textContent.String(); strings.Contains(strings.ToLower(text), "dsml") || strings.Contains(text, "Bash") { + t.Fatalf("repeated-prefix DSML tool call leaked to text: %q events=%#v", text, events) + } + if toolCalls != 1 { + t.Fatalf("expected one repeated-prefix DSML tool call, got %d events=%#v", toolCalls, events) + } +} + func TestProcessToolSieveHandlesLongXMLToolCall(t *testing.T) { var state State const toolName = "write_to_file" @@ -442,6 +533,8 @@ func TestFindToolSegmentStartDetectsXMLToolCalls(t *testing.T) { want int }{ {"tool_calls_tag", "some text \n", 10}, + {"dsml_trailing_pipe_tag", "some text <|DSML|tool_calls| \n", 10}, + {"dsml_extra_leading_less_than", "some text <<|DSML|tool_calls>\n", 10}, {"invoke_tag_missing_wrapper", "some text \n", 10}, {"bare_tool_call_text", "prefix \n", -1}, {"xml_inside_code_fence", "```xml\n\n```", -1}, @@ -465,6 +558,8 @@ func TestFindPartialXMLToolTagStart(t *testing.T) { want int }{ {"partial_tool_calls", "Hello best { + best = v + } + } + return best +} diff --git a/internal/util/token_count_heuristic.go b/internal/util/token_count_heuristic.go new file mode 100644 index 0000000..8839111 --- /dev/null +++ b/internal/util/token_count_heuristic.go @@ -0,0 +1,7 @@ +//go:build 386 || arm || mips || mipsle || wasm + +package util + +func countWithTokenizer(_, _ string) int { + return 0 +} diff --git a/internal/util/token_count_tiktoken.go b/internal/util/token_count_tiktoken.go new file mode 100644 index 0000000..f9cecf9 --- /dev/null +++ b/internal/util/token_count_tiktoken.go @@ -0,0 +1,44 @@ +//go:build !386 && !arm && !mips && !mipsle && !wasm + +package util + +import ( + "strings" + + tiktoken "github.com/hupe1980/go-tiktoken" +) + +func countWithTokenizer(text, model string) int { + text = strings.TrimSpace(text) + if text == "" { + return 0 + } + encoding, err := tiktoken.NewEncodingForModel(tokenizerModelForCount(model)) + if err != nil { + return 0 + } + ids, _, err := encoding.Encode(text, nil, nil) + if err != nil { + return 0 + } + return len(ids) +} + +func tokenizerModelForCount(model string) string { + model = strings.ToLower(strings.TrimSpace(model)) + if model == "" { + return defaultTokenizerModel + } + switch { + case strings.HasPrefix(model, "claude"): + return claudeTokenizerModel + case strings.HasPrefix(model, "gpt-4"), strings.HasPrefix(model, "gpt-5"), strings.HasPrefix(model, "o1"), strings.HasPrefix(model, "o3"), strings.HasPrefix(model, "o4"): + return defaultTokenizerModel + case strings.HasPrefix(model, "deepseek-v4"): + return defaultTokenizerModel + case strings.HasPrefix(model, "deepseek"): + return defaultTokenizerModel + default: + return defaultTokenizerModel + } +} diff --git a/plans/node-syntax-gate-targets.txt b/plans/node-syntax-gate-targets.txt index e466ff0..be933a0 100644 --- a/plans/node-syntax-gate-targets.txt +++ b/plans/node-syntax-gate-targets.txt @@ -20,4 +20,3 @@ internal/js/helpers/stream-tool-sieve/sieve-xml.js internal/js/helpers/stream-tool-sieve/jsonscan.js internal/js/helpers/stream-tool-sieve/parse.js internal/js/helpers/stream-tool-sieve/format.js -internal/js/helpers/stream-tool-sieve/tool-keywords.js diff --git a/tests/node/chat-stream.test.js b/tests/node/chat-stream.test.js index dbfbe3e..ba5ef7d 100644 --- a/tests/node/chat-stream.test.js +++ b/tests/node/chat-stream.test.js @@ -210,6 +210,37 @@ test('vercel stream retries empty output once and keeps one terminal frame', asy assert.match(completionBodies[1].prompt, /Previous reply had no visible output\. Please regenerate the visible final answer or tool call now\.$/); }); +test('vercel stream coalesces many small content deltas while keeping one choice', async () => { + const lines = Array.from({ length: 100 }, () => `data: ${JSON.stringify({ p: 'response/content', v: '字' })}\n\n`); + lines.push('data: [DONE]\n\n'); + const { frames } = await runMockVercelStream(lines); + const parsed = frames.filter((frame) => frame !== '[DONE]').map((frame) => JSON.parse(frame)); + const contentFrames = parsed.filter((item) => item.choices?.[0]?.delta?.content); + const content = contentFrames.map((item) => item.choices[0].delta.content).join(''); + assert.equal(content, '字'.repeat(100)); + assert.ok(contentFrames.length < 100, `expected fewer than 100 content frames, got ${contentFrames.length}`); + for (const item of parsed) { + assert.equal(item.choices.length, 1); + } +}); + +test('vercel stream flushes reasoning before content and before stop', async () => { + const { frames } = await runMockVercelStream([ + `data: ${JSON.stringify({ p: 'response/fragments', o: 'APPEND', v: [ + { type: 'THINK', content: '思考' }, + { type: 'THINK', content: '过程' }, + { type: 'RESPONSE', content: '回答' }, + ] })}\n\n`, + 'data: [DONE]\n\n', + ], { thinking_enabled: true }); + const parsed = frames.filter((frame) => frame !== '[DONE]').map((frame) => JSON.parse(frame)); + const reasoning = parsed.map((item) => item.choices?.[0]?.delta?.reasoning_content || '').join(''); + const content = parsed.map((item) => item.choices?.[0]?.delta?.content || '').join(''); + assert.equal(reasoning, '思考过程'); + assert.equal(content, '回答'); + assert.equal(parsed.at(-1).choices[0].finish_reason, 'stop'); +}); + test('vercel stream exhausts DeepSeek continue before synthetic retry', async () => { const { frames, fetchURLs, fetchBodies } = await runMockVercelStreamSequence([ [ @@ -227,6 +258,42 @@ test('vercel stream exhausts DeepSeek continue before synthetic retry', async () assert.equal(fetchBodies.some((body) => String(body.prompt || '').includes('Previous reply had no visible output')), false); }); +test('vercel stream continues direct quasi_status incomplete before final tool call', async () => { + const { frames, fetchURLs } = await runMockVercelStreamSequence([ + [ + 'data: {"response_message_id":7,"p":"response/content","v":""}\n\n', + 'data: {"p":"response/status","v":"FINISHED"}\n\n', + 'data: [DONE]\n\n', + ], + ], { tool_names: ['write_file'] }); + const parsed = frames.filter((frame) => frame !== '[DONE]').map((frame) => JSON.parse(frame)); + const toolDelta = parsed.find((item) => item.choices?.[0]?.delta?.tool_calls); + assert.equal(fetchURLs.filter((url) => url === 'https://chat.deepseek.com/api/v0/chat/continue').length, 1); + assert.ok(toolDelta); + const args = JSON.parse(toolDelta.choices[0].delta.tool_calls[0].function.arguments); + assert.equal(args.content, 'part-one-part-two'); + assert.equal(parsed.at(-1).choices[0].finish_reason, 'tool_calls'); +}); + + + +test('vercel stream usage completion_tokens does not double-count visible output', async () => { + const sample = 'abcdefghijklmnopqrst'; + const { frames } = await runMockVercelStream([ + `data: ${JSON.stringify({ p: 'response/content', v: sample })}\n\n`, + 'data: [DONE]\n\n', + ]); + const parsed = frames.filter((frame) => frame !== '[DONE]').map((frame) => JSON.parse(frame)); + const terminal = parsed.find((item) => Array.isArray(item.choices) && item.choices[0] && item.choices[0].finish_reason); + assert.ok(terminal); + assert.equal(terminal.usage.completion_tokens, 5); +}); + test('vercel stream reuses prior PoW when refresh fails', async () => { const originalFetch = global.fetch; const fetchURLs = []; @@ -497,14 +564,23 @@ test('parseChunkForContent drops thinking content when thinking is disabled', () 'text', ); assert.equal(thinking.finished, false); - assert.equal(thinking.newType, 'text'); + assert.equal(thinking.newType, 'thinking'); assert.deepEqual(thinking.parts, []); + const hiddenContinuation = parseChunkForContent( + { v: 'still hidden' }, + false, + thinking.newType, + ); + assert.equal(hiddenContinuation.newType, 'thinking'); + assert.deepEqual(hiddenContinuation.parts, []); + const answer = parseChunkForContent( { p: 'response/content', v: 'visible answer' }, false, - thinking.newType, + hiddenContinuation.newType, ); + assert.equal(answer.newType, 'text'); assert.deepEqual(answer.parts, [{ text: 'visible answer', type: 'text' }]); }); @@ -525,6 +601,12 @@ test('parseChunkForContent supports wrapped response.fragments object shape', () assert.equal(parsed.parts.map((p) => p.text).join(''), 'AB'); }); +test('parseChunkForContent reads object-shaped response/content payloads (Go parity)', () => { + const parsed = parseChunkForContent({ p: 'response/content', v: { text: 'vision text' } }, false, 'text', true); + assert.equal(parsed.parsed, true); + assert.deepEqual(parsed.parts, [{ text: 'vision text', type: 'text' }]); +}); + test('parseChunkForContent preserves space-only content tokens', () => { const chunk = { p: 'response/content', diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index f8265f7..228d519 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -57,6 +57,49 @@ test('parseToolCalls parses DSML shell as XML-compatible tool call', () => { assert.deepEqual(calls[0].input, { path: 'README.MD' }); }); +test('parseToolCalls tolerates DSML trailing pipe tag terminator', () => { + const payload = [ + '<|DSML|tool_calls| ', + ' <|DSML|invoke name="terminal">', + ' <|DSML|parameter name="command">', + ' <|DSML|parameter name="timeout">', + ' ', + '', + ].join('\n'); + const calls = parseToolCalls(payload, ['terminal']); + assert.equal(calls.length, 1); + assert.equal(calls[0].name, 'terminal'); + assert.deepEqual(calls[0].input, { command: 'find "/home" -type d', timeout: 10 }); +}); + +test('parseToolCalls tolerates extra leading less-than before DSML tags', () => { + const payload = [ + '<<|DSML|tool_calls>', + ' <<|DSML|invoke name="Bash">', + ' <<|DSML|parameter name="command">', + ' ', + '', + ].join('\n'); + const calls = parseToolCalls(payload, ['Bash']); + assert.equal(calls.length, 1); + assert.equal(calls[0].name, 'Bash'); + assert.deepEqual(calls[0].input, { command: 'pwd' }); +}); + +test('parseToolCalls tolerates repeated DSML prefix noise', () => { + const payload = [ + '<', + ' <', + ' <', + ' ', + '', + ].join('\n'); + const calls = parseToolCalls(payload, ['Bash']); + assert.equal(calls.length, 1); + assert.equal(calls[0].name, 'Bash'); + assert.deepEqual(calls[0].input, { command: 'git status' }); +}); + test('parseToolCalls tolerates DSML space-separator typo', () => { const payload = '<|DSML tool_calls><|DSML invoke name="Read"><|DSML parameter name="file_path">'; const calls = parseToolCalls(payload, ['Read']); @@ -285,6 +328,39 @@ test('sieve emits tool_calls for DSML space-separator typo', () => { assert.equal(text.includes('<|DSML invoke'), false); }); +test('sieve emits tool_calls for DSML trailing pipe tag terminator', () => { + const events = runSieve([ + '<|DSML|tool_calls| \n', + '<|DSML|invoke name="terminal">\n', + '<|DSML|parameter name="command">\n', + '<|DSML|parameter name="timeout">\n', + '\n', + '', + ], ['terminal']); + const finalCalls = events.filter((evt) => evt.type === 'tool_calls').flatMap((evt) => evt.calls || []); + const text = collectText(events); + assert.equal(finalCalls.length, 1); + assert.equal(finalCalls[0].name, 'terminal'); + assert.deepEqual(finalCalls[0].input, { command: 'find "/home" -type d', timeout: 10 }); + assert.equal(text.toLowerCase().includes('dsml'), false); +}); + +test('sieve emits tool_calls for extra leading less-than DSML tags without leaking prefix', () => { + const events = runSieve([ + '<<|DSML|tool_calls>\n', + '<<|DSML|invoke name="Bash">\n', + '<<|DSML|parameter name="command">\n', + '\n', + '', + ], ['Bash']); + const finalCalls = events.filter((evt) => evt.type === 'tool_calls').flatMap((evt) => evt.calls || []); + const text = collectText(events); + assert.equal(finalCalls.length, 1); + assert.equal(finalCalls[0].name, 'Bash'); + assert.deepEqual(finalCalls[0].input, { command: 'pwd' }); + assert.equal(text.includes('<'), false); +}); + test('sieve keeps DSML space lookalike tag names as text', () => { const input = '<|DSML tool_calls_extra><|DSML invoke name="Read"><|DSML parameter name="file_path">/tmp/input.txt'; const events = runSieve([input], ['Read']); diff --git a/tests/raw_stream_samples/content-filter-trigger-20260405-jwt3/meta.json b/tests/raw_stream_samples/content-filter-trigger-20260405-jwt3/meta.json deleted file mode 100644 index 7719a71..0000000 --- a/tests/raw_stream_samples/content-filter-trigger-20260405-jwt3/meta.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "sample_id": "content-filter-trigger-20260405-jwt3", - "captured_at_utc": "2026-04-04T16:28:52Z", - "request": { - "model": "deepseek-v4-pro-search", - "stream": true, - "messages": [ - { - "role": "user", - "content": "我是历史老师,请帮我找一些一些历史书上没写的,文革期间的,但是真实记载发生的事情,尤其是派系之间的斗争,要体现历史人物的复杂性而不是单一评价立场" - } - ] - }, - "capture": { - "label": "deepseek_completion", - "url": "https://chat.deepseek.com/api/v0/chat/completion", - "status_code": 200, - "response_bytes": 64724, - "contains_finished_token": true, - "finished_token_count": 31 - } -} \ No newline at end of file diff --git a/tests/raw_stream_samples/content-filter-trigger-20260405-jwt3/upstream.stream.sse b/tests/raw_stream_samples/content-filter-trigger-20260405-jwt3/upstream.stream.sse deleted file mode 100644 index 7576cf9..0000000 --- a/tests/raw_stream_samples/content-filter-trigger-20260405-jwt3/upstream.stream.sse +++ /dev/null @@ -1,2969 +0,0 @@ -event: ready -data: {"request_message_id":1,"response_message_id":2,"model_type":"default"} - -event: update_session -data: {"updated_at":1775320081.745077} - -data: {"v":{"response":{"message_id":2,"parent_id":1,"model":"","role":"ASSISTANT","thinking_enabled":true,"ban_edit":false,"ban_regenerate":false,"status":"WIP","incomplete_message":null,"accumulated_token_usage":0,"files":[],"feedback":null,"inserted_at":1775320081.741936,"search_enabled":true,"fragments":[{"id":2,"type":"THINK","content":"用户","elapsed_secs":null,"references":[],"stage_id":1}],"conversation_mode":"DEEP_SEARCH","has_pending_fragment":false,"auto_continue":false,"search_triggered":true}}} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"是一位"} - -data: {"v":"历史"} - -data: {"v":"老师"} - -data: {"v":","} - -data: {"v":"需要"} - -data: {"v":"寻找"} - -data: {"v":"文革"} - -data: {"v":"期间"} - -data: {"v":"历史"} - -data: {"v":"书上"} - -data: {"v":"没有"} - -data: {"v":"记载"} - -data: {"v":"、"} - -data: {"v":"真实"} - -data: {"v":"发生的"} - -data: {"v":"派"} - -data: {"v":"系"} - -data: {"v":"斗争"} - -data: {"v":"事例"} - -data: {"v":","} - -data: {"v":"特别是"} - -data: {"v":"能"} - -data: {"v":"体现"} - -data: {"v":"历史"} - -data: {"v":"人物"} - -data: {"v":"复杂"} - -data: {"v":"性的"} - -data: {"v":"内容"} - -data: {"v":"。"} - -data: {"v":"用户"} - -data: {"v":"明确"} - -data: {"v":"要求"} - -data: {"v":"提供"} - -data: {"v":"多个"} - -data: {"v":"具体"} - -data: {"v":"事例"} - -data: {"v":","} - -data: {"v":"并"} - -data: {"v":"强调"} - -data: {"v":"要"} - -data: {"v":"体现"} - -data: {"v":"复杂性"} - -data: {"v":"而非"} - -data: {"v":"单一"} - -data: {"v":"评价"} - -data: {"v":"立场"} - -data: {"v":"。"} - -data: {"v":"为了"} - -data: {"v":"全面"} - -data: {"v":"获取"} - -data: {"v":"信息"} - -data: {"v":","} - -data: {"v":"我将"} - -data: {"v":"同时"} - -data: {"v":"进行"} - -data: {"v":"多项"} - -data: {"v":"搜索"} - -data: {"v":","} - -data: {"v":"涵盖"} - -data: {"v":"不同类型的"} - -data: {"v":"派"} - -data: {"v":"系"} - -data: {"v":"斗争"} - -data: {"v":"、"} - -data: {"v":"具体"} - -data: {"v":"事件"} - -data: {"v":"、"} - -data: {"v":"原始"} - -data: {"v":"材料"} - -data: {"v":"以及"} - -data: {"v":"相关"} - -data: {"v":"研究"} - -data: {"v":"。"} - -data: {"p":"response/fragments/-1/elapsed_secs","o":"SET","v":1.8544618019999999} - -data: {"p":"response/has_pending_fragment","v":true} - -: - -data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":3,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"文革 派系斗争 事例 历史人物 复杂性 非官方记载"}],"results":[],"stage_id":1},{"id":4,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"文革 群众组织 派系斗争 武斗 事例"}],"results":[],"stage_id":1},{"id":5,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"王力 关锋 戚本禹 文革 派系 中央文革小组"}],"results":[],"stage_id":1},{"id":6,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"武汉七二〇事件 详细经过 派系 人物"}],"results":[],"stage_id":1},{"id":7,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"李雪峰 文革 派系 人物 复杂性"}],"results":[],"stage_id":1},{"id":8,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"赵健民 云南 文革 滇西挺进纵队 冤案"}],"results":[],"stage_id":1},{"id":9,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"文革 派系斗争 原始材料 回忆录 口述史 研究"}],"results":[],"stage_id":1},{"id":10,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"site:cnki.net 文革 派系斗争 历史人物 复杂性"}],"results":[],"stage_id":1}]},{"p":"has_pending_fragment","o":"SET","v":false}]} - -data: {"p":"response/fragments/-7/results","o":"SET","v":[{"url":"http://www.zzdsw.com/yjcg/ztyj/201603/t20160331_1521613.html","title":"专题研究- 枣庄党史史志网","snippet":"发生在1967年7月25日至26日,是文化大革命中枣庄地区两派不同观点群众,发生的较大规模的武斗流血事件,也是枣庄地区两派群众组织派性激化的表现。","cite_index":null,"published_at":1459353600.0,"site_icon":"https://cdn.deepseek.com/site-icons/zzdsw.com","site_name":"zzdsw.com","query_indexes":[0]},{"url":"https://www.hinews.cn:443/news/system/2009/10/16/010588314_01.shtml","title":"“文革”进入狂热时期 “一月风暴”影响海南","snippet":"派性的斗争使得局势变得错综复杂,越来越多的群众参与进来,形成了群众组织派性斗争。由于斗争激烈,扰乱了夺权运动的大方向,并给生产带来了严重影响。海南农垦也被牵扯进来了。","cite_index":null,"published_at":1255622400.0,"site_icon":"https://cdn.deepseek.com/site-icons/hinews.cn","site_name":"南海网","query_indexes":[0]},{"url":"https://m.thepaper.cn/baijiahao_15235200","title":"中华人民共和国成立初期的政治建设(四)","snippet":"9 月,“八五风暴”与“筹委会”两大派对立,在县供销社大楼发生第一次大型武斗事件,全县广播中断,毁坏房屋数十间,伤 30 余人。此后,接连发生了抢县武装部弹药库事件,武斗愈演愈烈。1968 年 1 月...","cite_index":null,"published_at":1635868800.0,"site_icon":"https://cdn.deepseek.com/site-icons/thepaper.cn","site_name":"澎湃新闻","query_indexes":[0]},{"url":"http://zx.hanzhong.gov.cn/hzzxwz/thhm/201605/t20160510_330408.shtml","title":"“文革”期间勉县武斗和“六·二”事件","snippet":"比较大的武斗事件21起,死亡85人(其中打死47人,武器走火死亡12人,武斗汽车肇事死亡13人,武斗中触电死亡1人,打死无辜群众3人,打死中国人民解放军战士9人)。","cite_index":null,"published_at":1618588800.0,"site_icon":"https://cdn.deepseek.com/site-icons/hanzhong.gov.cn","site_name":"汉中市人民政府","query_indexes":[0]},{"url":"http://www.gxdfz.org.cn/flbg/lsrw/202602/t20260224_67313.html","title":"余华祥--广西地情网","snippet":"1967年下半年以后,平南两派群众组织进入武斗高潮,余华祥经常身挎冲锋枪,腰缠手榴弹,进出县城,参加武斗。其时,广西劳动大学生梁宗和(寺面人),在南宁武斗时潜回寺面,后大安开始大规模武斗,连续出现枪杀群众事件,梁宗和怕受牵连,于12月1日取道六陈、罗秀到桂平想回南宁...","cite_index":null,"published_at":1771862400.0,"site_icon":"https://cdn.deepseek.com/site-icons/gxdfz.org.cn","site_name":"广西数字方志馆","query_indexes":[0]}]} - -data: {"p":"response/fragments/-7/status","v":"FINISHED"} - -data: {"p":"response/fragments/-8/results","v":[{"url":"http://jhwsw.com/zzdzb/ShowArticle.asp?ArticleID=838","title":"“文革”期间的“反复旧妖风”(唐张新)","snippet":"其实从“文革”开始之初,红卫兵因为政治立场和投身运动的动机各不相同,对待原先当权派的态度就有两派之分,其中主张彻底打倒、严酷斗争的自诩为“左派”,而把那些支持原来老革命当权者的贬称为“保皇派”。到后来...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/jhwsw.com","site_name":"Jhwsw","query_indexes":[0]},{"url":"https://m.thepaper.cn/newsDetail_forward_8348578","title":"孟祥才 | 冯友兰为何能逃脱“右派”的厄运?","snippet":"历史所造反派翻身后,通过“民选”产生了“文革”小组,全面领导所里的“文化革命”。其中一项重要的工作是建立尹达的专案组,搜集整理他“执行修正主义路线的材料”。","cite_index":null,"published_at":1595088000.0,"site_icon":"https://cdn.deepseek.com/site-icons/thepaper.cn","site_name":"澎湃新闻","query_indexes":[0]},{"url":"http://mztoday.gov.cn/show/46607.html","title":"今日绵竹 - 《中国共产党四川省绵竹市历史》之“文化大革命”在绵竹兴起(二)","snippet":"1966年12月17日,绵竹县教育系统群众组织“小教兵团”在原老川剧团剧院集中全城区中小学教师及部分其他单位的干部数百人举行批判“三家村”斗争大会。批判斗争当权派是“文化大革命”的重要形式和内容。期间...","cite_index":null,"published_at":1627228800.0,"site_icon":"https://cdn.deepseek.com/site-icons/mztoday.gov.cn","site_name":"今日绵竹","query_indexes":[0]},{"url":"https://www.zjkzx.gov.cn/book/history17/files/basic-html/page76.html","title":"Page 76 - 张家口政协","snippet":"“文革”爆发后,他被隔离审查,仍坚持不承认反党。他把申 诉材料缝在棉袄里,被造反派查出,又多了一个“妄图翻案”","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/zjkzx.gov.cn","site_name":"Zjkzx","query_indexes":[0]}]} - -data: {"p":"response/fragments/-8/status","v":"FINISHED"} - -data: {"p":"response/fragments/-2/results","v":[{"url":"https://chinafolklore.org/web/index.php?Page=2&NewsID=5478","title":"[英古阿格]一个纳西族老村长的口述实录 · 中国民俗学网-中国民俗学会","snippet":"文革期间,因为有些造反派想打倒我,但又找不到把柄,因为我才当了村长不到三个月,就想定罪为当权派,他们也没有理由。家庭一直是根正苗红,建国之初就是团员了。他们就查到我家二叔头上来...","cite_index":null,"published_at":1249660800.0,"site_icon":"https://cdn.deepseek.com/site-icons/chinafolklore.org","site_name":"中国民俗学网","query_indexes":[0]},{"url":"http://old.counsellor.gov.cn/wsxc/2009-10-24/10368.shtml","title":"叶永烈:我写“文革”史","snippet":"史学研究历来重视文献,文献的“文”指档案材料,“献”就是口述史。我认为当代人写当代史的优势就在于对“献”的重视。那些档案,现在还处于保密阶段的,有可能过个几十年、上百年,后人能很方便地查阅。但是当事人越来越少...","cite_index":null,"published_at":1256313600.0,"site_icon":"https://cdn.deepseek.com/site-icons/counsellor.gov.cn","site_name":"国务院参事室","query_indexes":[0]},{"url":"https://news.sina.com.cn/c/sd/2009-07-31/131018338282.shtml","title":"亲历者口述“四人帮”兴亡史_新闻中心_新浪网","snippet":"中国历来有官修历史和民间著史的传统,一段由作家采写的10年“文革”史,历经20多年的等待,终于问世。 建国60周年,10年是“文革”,这段历史无法跨越。除了叶永烈的《“四人帮”兴亡》...","cite_index":null,"published_at":1248969600.0,"site_icon":"https://cdn.deepseek.com/site-icons/sina.com.cn","site_name":"新浪网","query_indexes":[0]}]} - -data: {"p":"response/fragments/-2/status","v":"FINISHED"} - -data: {"p":"response/fragments/-6/results","v":[{"url":"http://zhouenlai.people.cn/BIG5/n1/2019/0725/c409117-31256646-4.html","title":"揭秘\"救火隊長\"周恩來如何在\"文革\"中苦撐危局【4】","snippet":"1967年2月后,中央文革小組實際上取代了中央政治局,更是不可一世,到處煽陰風、點鬼火。這時的王力、關鋒、戚本禹是中央文革的三員“干將”,江青手下名副其實的“車、馬、炮”,成為“文化大革命”中左右橫行的風雲人物。","cite_index":null,"published_at":1563984000.0,"site_icon":"https://cdn.deepseek.com/site-icons/people.cn","site_name":"周恩来纪念网","query_indexes":[0]}]} - -data: {"p":"response/fragments/-6/status","v":"FINISHED"} - -data: {"p":"response/fragments/-1/results","v":[{"url":"https://bianke.cnki.net/Web/SearchArticle?kw=%E8%AE%B8%E5%B9%BF%E5%B9%B3&field=0&page=9","title":"母亲、女校长、问罪学——关于杨荫榆事件的再思考","snippet":"许广平在“文革”中未曾点成仿吾的名"文革"初,山东大学全校人员分成两派,对学校党委和党委书记、校长成仿吾的功过问题展开辩论。成仿吾开始"靠边站"...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/cnki.net","site_name":"知网百科","query_indexes":[0]},{"url":"https://wap.cnki.net/touch/web/Journal/Article/BNCH200103007.html","title":"师门忆语(三)-百年潮2001年03期-手机知网","snippet":"十一、“文革”风暴中议论陈伯达“文革”祸起,《红旗》杂志的三位副总编辑邓(力群)、胡(绳)、范[若愚],几乎一夜之间就从领导高位上跌落下来,被作为“走资派”打倒。胡绳还因为参加“二月提纲”而罪加一等...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/cnki.net","site_name":"CNKI","query_indexes":[0]},{"url":"https://xuewen.cnki.net/R2006050150005248.html","title":"中国科学院第4次学部委员大会","snippet":"全院工作稳步走上正轨。“文革”期间,中科院是重灾区,院中派系斗争不断,科学家和领导干部横遭批判和迫害,局面异常混乱。1977年1月方毅主持全院工作后,抓了粉碎“四人帮”帮派体系的工作,努力清除“左”的干扰。而且在院领导班子的带领下...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/cnki.net","site_name":"CNKI","query_indexes":[0]},{"url":"https://mall.cnki.net/magazine/Article/DDSJ201204035.htm","title":"文革时期的海外中共研究","snippet":"文革时期是海外中共研究的兴盛时期。随着研究资料状况的改善,海外学者这一时期的研究成果体现了中共研究范围和研究深度的拓展。尤其重要的是,这一时期海外中共研究的进展,代表了研究范式的转变:原先主导海外中共研究领域的\"极权主义\"模式分崩离析,以\"派系... (共5页)","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/cnki.net","site_name":"知网阅读","query_indexes":[0]},{"url":"https://mall.cnki.net/magazine/article/FLWS200309000.htm","title":"“文革”发动前的一段经历——从《二月提纲》到东湖之行","snippet":"“七个攻击左派、包庇右派材料”的出台 《二月提纲》的主持者是彭真,“炮制者”是中宣部的两位副部长,许立群和姚溱。 许立群是在周扬住院割治肺癌后,于1966年1月接手分管学术批判的事的。据我的观察...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/cnki.net","site_name":"知网阅读","query_indexes":[0]}]} - -data: {"p":"response/fragments/-1/status","v":"FINISHED"} - -data: {"p":"response/fragments/-4/results","v":[{"url":"http://www.qyhao123.com/xcx/discuz.php?mod=view&tid=58251&show=author","title":"哪位政治局常委只当了四个月的党内“第四号人物”?","snippet":"“文革”初期,全国6个大区中两个大区的一把手,即华北局第一书记李雪峰、中南局第一书记陶铸,受到重用:李雪峰任北京市委第一书记,陶铸任中宣部长。毛泽东为什么选择他们取代被打倒的彭真...","cite_index":null,"published_at":1378656000.0,"site_icon":"https://cdn.deepseek.com/site-icons/qyhao123.com","site_name":"祁阳生活网","query_indexes":[0]}]} - -data: {"p":"response/fragments/-4/status","v":"FINISHED"} - -data: {"p":"response/fragments/-5/status","v":"FINISHED"} - -data: {"p":"response/fragments/-3/results","v":[{"url":"http://hbzx.tsxcfw.com/bookshow.asp?id=887815","title":"康生与赵健民冤案_丁龙嘉,听雨_9787010028347_河北知行文化传播有限公司","snippet":"“赵健民特务案”是“文革”期间康生一手制造的一起特大冤案。这起冤案,使身为云南省委负责人的赵人健民被非法关押长达八年,一百三十八万七千余人被审查,十七万五千余作被立案审查,一万七千余人被打死、逼死。 作者耗时十年...","cite_index":null,"published_at":1159632000.0,"site_icon":"https://cdn.deepseek.com/site-icons/tsxcfw.com","site_name":"tsxcfw.com","query_indexes":[0]},{"url":"http://news.big5.enorth.com.cn/system/2008/10/21/003732191.shtml","title":"史海:康生制造冤案 雲南省委書記被非法關押8年-新聞中心-北方網","snippet":"趙健民冤案,是文革期間康生一手制造的一起特大冤案,使身為雲南省委書記的趙健民被非法關押達8年之久。《康生與『趙健民冤案』》一書是專家治史之作。","cite_index":null,"published_at":1224518400.0,"site_icon":"https://cdn.deepseek.com/site-icons/enorth.com.cn","site_name":"北方网","query_indexes":[0]},{"url":"https://m.163.com/dy/article/HTT55SI90552PRXR.html?spss=adap_pc","title":"他当过云南省委书记,晚年回忆:因为康生一句话,1万多人被害死","snippet":"在这之后,康生围绕赵健民制造了骇人听闻的“赵健民冤案”,把时任云南省委书记的赵健民诬陷为“叛徒”、“国民党特务”,赵健民也被关押起来,并被非法关押了八年时间。同时,受这起冤案牵连,云南省有一万七千余人被迫害致死。","cite_index":null,"published_at":1676736000.0,"site_icon":"https://cdn.deepseek.com/site-icons/163.com","site_name":"网易","query_indexes":[0]},{"url":"https://ccrhm.org/history/yanshutian.htm","title":"阎书田","snippet":"文革中属云南“炮派”观点 ... 在会议中残酷批斗后押回云南原单位,在批斗会上被活活打死。 (资料来源于丁龙嘉、听雨著《康生与“赵健民冤案”》,人民出版社1999年1月版","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/ccrhm.org","site_name":"中国文革受难者纪念园","query_indexes":[0]},{"url":"https://ccrhm.org/history/gaotangwen.htm","title":"高堂文","snippet":"在会议期间批斗会上被打掉了下巴,押回原单位后在批斗中被铁丝活活勒死。 (资料来源于丁龙嘉、听雨著《康生与“赵健民冤案”》,人民出版社","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/ccrhm.org","site_name":"中国文革受难者纪念园","query_indexes":[0]}]} - -data: {"p":"response/fragments/-3/status","v":"FINISHED"} - -data: {"p":"response/has_pending_fragment","v":true} - -data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":11,"type":"THINK","content":"搜索","elapsed_secs":null,"references":[],"stage_id":2}]},{"p":"has_pending_fragment","o":"SET","v":false}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"结果显示"} - -data: {"v":"了一些"} - -data: {"v":"相关"} - -data: {"v":"页面"} - -data: {"v":"。"} - -data: {"v":"为了"} - -data: {"v":"全面"} - -data: {"v":"获取"} - -data: {"v":"信息"} - -data: {"v":","} - -data: {"v":"我们需要"} - -data: {"v":"同时"} - -data: {"v":"打开"} - -data: {"v":"这些"} - -data: {"v":"页面"} - -data: {"v":"。"} - -data: {"p":"response/fragments/-1/elapsed_secs","o":"SET","v":0.741001591} - -data: {"p":"response/has_pending_fragment","v":true} - -: - -: - -data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":12,"type":"TOOL_OPEN","status":"WIP","result":{"url":"http://jhwsw.com/zzdzb/ShowArticle.asp?ArticleID=838","title":"“文革”期间的“反复旧妖风”(唐张新)","snippet":"其实从“文革”开始之初,红卫兵因为政治立场和投身运动的动机各不相同,对待原先当权派的态度就有两派之分,其中主张彻底打倒、严酷斗争的自诩为“左派”,而把那些支持原来老革命当权者的贬称为“保皇派”。到后来...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/jhwsw.com","site_name":"Jhwsw","query_indexes":[0]},"reference":{"id":3,"type":"TOOL_SEARCH"},"stage_id":2},{"id":13,"type":"TOOL_OPEN","status":"WIP","result":{"url":"http://www.zzdsw.com/yjcg/ztyj/201603/t20160331_1521613.html","title":"专题研究- 枣庄党史史志网","snippet":"发生在1967年7月25日至26日,是文化大革命中枣庄地区两派不同观点群众,发生的较大规模的武斗流血事件,也是枣庄地区两派群众组织派性激化的表现。","cite_index":null,"published_at":1459353600.0,"site_icon":"https://cdn.deepseek.com/site-icons/zzdsw.com","site_name":"zzdsw.com","query_indexes":[0]},"reference":{"id":4,"type":"TOOL_SEARCH"},"stage_id":2},{"id":14,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://m.thepaper.cn/baijiahao_15235200","title":"中华人民共和国成立初期的政治建设(四)","snippet":"9 月,“八五风暴”与“筹委会”两大派对立,在县供销社大楼发生第一次大型武斗事件,全县广播中断,毁坏房屋数十间,伤 30 余人。此后,接连发生了抢县武装部弹药库事件,武斗愈演愈烈。1968 年 1 月...","cite_index":null,"published_at":1635868800.0,"site_icon":"https://cdn.deepseek.com/site-icons/thepaper.cn","site_name":"澎湃新闻","query_indexes":[0]},"reference":{"id":4,"type":"TOOL_SEARCH"},"stage_id":2},{"id":15,"type":"TOOL_OPEN","status":"WIP","result":{"url":"http://zx.hanzhong.gov.cn/hzzxwz/thhm/201605/t20160510_330408.shtml","title":"“文革”期间勉县武斗和“六·二”事件","snippet":"比较大的武斗事件21起,死亡85人(其中打死47人,武器走火死亡12人,武斗汽车肇事死亡13人,武斗中触电死亡1人,打死无辜群众3人,打死中国人民解放军战士9人)。","cite_index":null,"published_at":1618588800.0,"site_icon":"https://cdn.deepseek.com/site-icons/hanzhong.gov.cn","site_name":"汉中市人民政府","query_indexes":[0]},"reference":{"id":4,"type":"TOOL_SEARCH"},"stage_id":2},{"id":16,"type":"TOOL_OPEN","status":"WIP","result":{"url":"http://zhouenlai.people.cn/BIG5/n1/2019/0725/c409117-31256646-4.html","title":"揭秘\"救火隊長\"周恩來如何在\"文革\"中苦撐危局【4】","snippet":"1967年2月后,中央文革小組實際上取代了中央政治局,更是不可一世,到處煽陰風、點鬼火。這時的王力、關鋒、戚本禹是中央文革的三員“干將”,江青手下名副其實的“車、馬、炮”,成為“文化大革命”中左右橫行的風雲人物。","cite_index":null,"published_at":1563984000.0,"site_icon":"https://cdn.deepseek.com/site-icons/people.cn","site_name":"周恩来纪念网","query_indexes":[0]},"reference":{"id":5,"type":"TOOL_SEARCH"},"stage_id":2},{"id":17,"type":"TOOL_OPEN","status":"WIP","result":{"url":"http://www.qyhao123.com/xcx/discuz.php?mod=view&tid=58251&show=author","title":"哪位政治局常委只当了四个月的党内“第四号人物”?","snippet":"“文革”初期,全国6个大区中两个大区的一把手,即华北局第一书记李雪峰、中南局第一书记陶铸,受到重用:李雪峰任北京市委第一书记,陶铸任中宣部长。毛泽东为什么选择他们取代被打倒的彭真...","cite_index":null,"published_at":1378656000.0,"site_icon":"https://cdn.deepseek.com/site-icons/qyhao123.com","site_name":"祁阳生活网","query_indexes":[0]},"reference":{"id":7,"type":"TOOL_SEARCH"},"stage_id":2},{"id":18,"type":"TOOL_OPEN","status":"WIP","result":{"url":"http://hbzx.tsxcfw.com/bookshow.asp?id=887815","title":"康生与赵健民冤案_丁龙嘉,听雨_9787010028347_河北知行文化传播有限公司","snippet":"“赵健民特务案”是“文革”期间康生一手制造的一起特大冤案。这起冤案,使身为云南省委负责人的赵人健民被非法关押长达八年,一百三十八万七千余人被审查,十七万五千余作被立案审查,一万七千余人被打死、逼死。 作者耗时十年...","cite_index":null,"published_at":1159632000.0,"site_icon":"https://cdn.deepseek.com/site-icons/tsxcfw.com","site_name":"tsxcfw.com","query_indexes":[0]},"reference":{"id":8,"type":"TOOL_SEARCH"},"stage_id":2},{"id":19,"type":"TOOL_OPEN","status":"WIP","result":{"url":"http://news.big5.enorth.com.cn/system/2008/10/21/003732191.shtml","title":"史海:康生制造冤案 雲南省委書記被非法關押8年-新聞中心-北方網","snippet":"趙健民冤案,是文革期間康生一手制造的一起特大冤案,使身為雲南省委書記的趙健民被非法關押達8年之久。《康生與『趙健民冤案』》一書是專家治史之作。","cite_index":null,"published_at":1224518400.0,"site_icon":"https://cdn.deepseek.com/site-icons/enorth.com.cn","site_name":"北方网","query_indexes":[0]},"reference":{"id":8,"type":"TOOL_SEARCH"},"stage_id":2},{"id":20,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://m.163.com/dy/article/HTT55SI90552PRXR.html?spss=adap_pc","title":"他当过云南省委书记,晚年回忆:因为康生一句话,1万多人被害死","snippet":"在这之后,康生围绕赵健民制造了骇人听闻的“赵健民冤案”,把时任云南省委书记的赵健民诬陷为“叛徒”、“国民党特务”,赵健民也被关押起来,并被非法关押了八年时间。同时,受这起冤案牵连,云南省有一万七千余人被迫害致死。","cite_index":null,"published_at":1676736000.0,"site_icon":"https://cdn.deepseek.com/site-icons/163.com","site_name":"网易","query_indexes":[0]},"reference":{"id":8,"type":"TOOL_SEARCH"},"stage_id":2},{"id":21,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://chinafolklore.org/web/index.php?Page=2&NewsID=5478","title":"[英古阿格]一个纳西族老村长的口述实录 · 中国民俗学网-中国民俗学会","snippet":"文革期间,因为有些造反派想打倒我,但又找不到把柄,因为我才当了村长不到三个月,就想定罪为当权派,他们也没有理由。家庭一直是根正苗红,建国之初就是团员了。他们就查到我家二叔头上来...","cite_index":null,"published_at":1249660800.0,"site_icon":"https://cdn.deepseek.com/site-icons/chinafolklore.org","site_name":"中国民俗学网","query_indexes":[0]},"reference":{"id":9,"type":"TOOL_SEARCH"},"stage_id":2},{"id":22,"type":"TOOL_OPEN","status":"WIP","result":{"url":"http://old.counsellor.gov.cn/wsxc/2009-10-24/10368.shtml","title":"叶永烈:我写“文革”史","snippet":"史学研究历来重视文献,文献的“文”指档案材料,“献”就是口述史。我认为当代人写当代史的优势就在于对“献”的重视。那些档案,现在还处于保密阶段的,有可能过个几十年、上百年,后人能很方便地查阅。但是当事人越来越少...","cite_index":null,"published_at":1256313600.0,"site_icon":"https://cdn.deepseek.com/site-icons/counsellor.gov.cn","site_name":"国务院参事室","query_indexes":[0]},"reference":{"id":9,"type":"TOOL_SEARCH"},"stage_id":2},{"id":23,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://news.sina.com.cn/c/sd/2009-07-31/131018338282.shtml","title":"亲历者口述“四人帮”兴亡史_新闻中心_新浪网","snippet":"中国历来有官修历史和民间著史的传统,一段由作家采写的10年“文革”史,历经20多年的等待,终于问世。 建国60周年,10年是“文革”,这段历史无法跨越。除了叶永烈的《“四人帮”兴亡》...","cite_index":null,"published_at":1248969600.0,"site_icon":"https://cdn.deepseek.com/site-icons/sina.com.cn","site_name":"新浪网","query_indexes":[0]},"reference":{"id":9,"type":"TOOL_SEARCH"},"stage_id":2},{"id":24,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://bianke.cnki.net/Web/SearchArticle?kw=%E8%AE%B8%E5%B9%BF%E5%B9%B3&field=0&page=9","title":"母亲、女校长、问罪学——关于杨荫榆事件的再思考","snippet":"许广平在“文革”中未曾点成仿吾的名"文革"初,山东大学全校人员分成两派,对学校党委和党委书记、校长成仿吾的功过问题展开辩论。成仿吾开始"靠边站"...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/cnki.net","site_name":"知网百科","query_indexes":[0]},"reference":{"id":10,"type":"TOOL_SEARCH"},"stage_id":2},{"id":25,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://wap.cnki.net/touch/web/Journal/Article/BNCH200103007.html","title":"师门忆语(三)-百年潮2001年03期-手机知网","snippet":"十一、“文革”风暴中议论陈伯达“文革”祸起,《红旗》杂志的三位副总编辑邓(力群)、胡(绳)、范[若愚],几乎一夜之间就从领导高位上跌落下来,被作为“走资派”打倒。胡绳还因为参加“二月提纲”而罪加一等...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/cnki.net","site_name":"CNKI","query_indexes":[0]},"reference":{"id":10,"type":"TOOL_SEARCH"},"stage_id":2},{"id":26,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://mall.cnki.net/magazine/Article/DDSJ201204035.htm","title":"文革时期的海外中共研究","snippet":"文革时期是海外中共研究的兴盛时期。随着研究资料状况的改善,海外学者这一时期的研究成果体现了中共研究范围和研究深度的拓展。尤其重要的是,这一时期海外中共研究的进展,代表了研究范式的转变:原先主导海外中共研究领域的\"极权主义\"模式分崩离析,以\"派系... (共5页)","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/cnki.net","site_name":"知网阅读","query_indexes":[0]},"reference":{"id":10,"type":"TOOL_SEARCH"},"stage_id":2}]},{"p":"has_pending_fragment","o":"SET","v":false}]} - -data: {"p":"response/fragments/-3/status","o":"SET","v":"FINISHED"} - -data: {"p":"response/fragments/-2/status","v":"FINISHED"} - -data: {"p":"response/fragments/-1/status","v":"FINISHED"} - -data: {"p":"response/fragments/-10/status","v":"FINISHED"} - -data: {"p":"response/fragments/-9/status","v":"FINISHED"} - -data: {"p":"response/fragments/-7/status","v":"FINISHED"} - -data: {"p":"response/fragments/-5/status","v":"FINISHED"} - -data: {"p":"response/fragments/-4/status","v":"FINISHED"} - -data: {"p":"response/fragments/-15/status","v":"FINISHED"} - -data: {"p":"response/fragments/-14/status","v":"FINISHED"} - -data: {"p":"response/fragments/-13/status","v":"FINISHED"} - -data: {"p":"response/fragments/-12/status","v":"FINISHED"} - -data: {"p":"response/fragments/-8/status","v":"FINISHED"} - -data: {"p":"response/fragments/-6/status","v":"FINISHED"} - -data: {"p":"response/fragments/-11/status","v":"FINISHED"} - -data: {"p":"response/has_pending_fragment","v":true} - -data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":27,"type":"THINK","content":"搜索","elapsed_secs":null,"references":[],"stage_id":3}]},{"p":"has_pending_fragment","o":"SET","v":false}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"结果显示"} - -data: {"v":"了一些"} - -data: {"v":"相关"} - -data: {"v":"页面"} - -data: {"v":"。"} - -data: {"v":"为了"} - -data: {"v":"更"} - -data: {"v":"全面地"} - -data: {"v":"覆盖"} - -data: {"v":"用户"} - -data: {"v":"需求"} - -data: {"v":","} - -data: {"v":"我们需要"} - -data: {"v":"同时"} - -data: {"v":"进行"} - -data: {"v":"多项"} - -data: {"v":"搜索"} - -data: {"v":","} - -data: {"v":"以"} - -data: {"v":"获取"} - -data: {"v":"更多"} - -data: {"v":"关于"} - -data: {"v":"派"} - -data: {"v":"系"} - -data: {"v":"斗争"} - -data: {"v":"、"} - -data: {"v":"具体"} - -data: {"v":"事件"} - -data: {"v":"、"} - -data: {"v":"地方"} - -data: {"v":"案例"} - -data: {"v":"、"} - -data: {"v":"高层"} - -data: {"v":"斗争"} - -data: {"v":"以及"} - -data: {"v":"不同"} - -data: {"v":"研究"} - -data: {"v":"视角"} - -data: {"v":"的信息"} - -data: {"v":"。"} - -data: {"p":"response/fragments/-1/elapsed_secs","o":"SET","v":1.177853049} - -data: {"p":"response/has_pending_fragment","v":true} - -: - -data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":28,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"文革 派系 斗争 群众组织 武斗 案例 研究"}],"results":[],"stage_id":3},{"id":29,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"王力 八七讲话 外交部 夺权 派系"}],"results":[],"stage_id":3},{"id":30,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"北京 红卫兵 派系 斗争 联动 天派 地派"}],"results":[],"stage_id":3},{"id":31,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"上海 文革 派系 斗争 工总司 联司"}],"results":[],"stage_id":3},{"id":32,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"广州 文革 派系 斗争 红旗派 东风派"}],"results":[],"stage_id":3},{"id":33,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"湖南 文革 派系 斗争 湘江风雷 红一线"}],"results":[],"stage_id":3},{"id":34,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"广西 文革 派系 斗争 联指 四二二 派"}],"results":[],"stage_id":3}]},{"p":"has_pending_fragment","o":"SET","v":false}]} - -data: {"p":"response/fragments/-4/results","o":"SET","v":[{"url":"https://m.163.com/dy/article/HL9HAKRH0552PRXR.html?spss=adap_pc","title":"他与王洪文一起创立了“工总司”,却被王洪文架空,后来两度入狱","snippet":"于是“工总司”就在王洪文以及潘国平等人的带领下前往上海市委,11月10日凌晨又乘坐火车前往安亭车站,制造了安亭事件。潘国平也是这起事件的带头人 ... 潘国平是“工总司”的早期领导人,与王洪文平起平坐。潘国平有一定的文化基础,为人比较外向...","cite_index":null,"published_at":1667491200.0,"site_icon":"https://cdn.deepseek.com/site-icons/163.com","site_name":"网易","query_indexes":[0]}]} - -data: {"p":"response/fragments/-4/status","v":"FINISHED"} - -data: {"p":"response/fragments/-5/results","v":[{"url":"https://baike.baidu.com/item/%E5%A4%A9%E6%B4%BE/9569880","title":"天派_百度百科","snippet":"天派,全称红卫兵天派,是文化大革命期间以北京航空学院“红旗”组织为核心力量的政治团体,主要活跃于北京地区。其名称源于北航的航空专业属性,“航”与“天”相关联,故简称“天派” [1。","cite_index":null,"published_at":1750867200.0,"site_icon":"https://cdn.deepseek.com/site-icons/baidu.com","site_name":"百度百科","query_indexes":[0]},{"url":"http://www.chinazhiqing.com/forum.php?mod=viewthread&action=printable&tid=102766","title":"中国知青网-中国知青网络家园","snippet":"红卫兵运动,发起者是首都各大专院校的大学生,首都红卫兵的“天派”是北航,“地派”是北京地质学院,窜遍全国掀起造反浪潮最著名的所谓首都红卫兵第三司令部,也基本以大学生为主!中学虽然有很多红卫兵组织...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/chinazhiqing.com","site_name":"中国知青网","query_indexes":[0]},{"url":"https://upimg.baike.haosou.com/doc/529356-560419.html","title":"天派 免费编辑 添加义项名","snippet":"\"天派\",全名红卫兵天派,指的是文革期间由北京航空学院\"红旗\"为主要力量的组织,北航的\"航\"即航空,是和天有关的,所以称为\"天派\"。","cite_index":null,"published_at":1535212800.0,"site_icon":"https://cdn.deepseek.com/site-icons/haosou.com","site_name":"haosou.com","query_indexes":[0]},{"url":"https://baike.baidu.com/searchword/?word=%E9%9F%A9%E7%88%B1%E6%99%B6&pic=1&sug=1&enc=utf8","title":"","snippet":"“天派”、“地派”的“领头羊”北大“新北大公社”、清华“井冈山”、北航“红旗”、地质“东方红”、师大“井冈山”等都是“文革”初期名震一方的“左派”组织,他们的头头聂元梓、蒯大富、 _韩爱晶_ 、谭厚兰、王大宾号称北京造反派红卫兵的“五大领袖”。中央文革小组则对两派采取又拉又捧...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/baidu.com","site_name":"百度百科","query_indexes":[0]}]} - -data: {"p":"response/fragments/-5/status","v":"FINISHED"} - -data: {"p":"response/fragments/-1/results","v":[{"url":"http://lib.gxdfz.org.cn/view2022-c32-480.html","title":"480 第五节 派性斗争与冤假错案","snippet":"1967年5月6日,县内群众组织开始分化为“联指”(无产阶级革命派联合指挥部)和“四 ·二二”(四·二二火线指挥部,后改为革命造反大军),两大派展开激烈的派性斗争,他们 互相争论、指责斗殴,你叫我“保皇派”,我叫你“匪徒派”。当年9月1日高岭区两派发生 冲突...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/gxdfz.org.cn","site_name":"Gxdfz","query_indexes":[0]},{"url":"http://lib.gxdfz.org.cn/view-c37-707.html","title":"707 第四节 派性斗争","snippet":"1967年秋冬,广西四二二钦州指挥郎(简称四·二二,又称造反派)、钦州县无产阶级革 命联合指挥部(简称联指)两大派群众组织矛盾越来越尖锐,相继出现互相抓人、抢枪事件, 大规模的示威游行不断发生。11月1日...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/gxdfz.org.cn","site_name":"Gxdfz","query_indexes":[0]},{"url":"http://www.gxdfz.org.cn/flbg/lsrw/202602/t20260224_67312.html","title":"杨明--广西地情网","snippet":"21日,“联指”胡北荣被冷枪打死。武斗便迅速推向高潮。22晚,杨明与谭朝东亲自指挥武装民兵用冲锋枪袭击“4·22”的据点——南江旅社。随后...","cite_index":null,"published_at":1771862400.0,"site_icon":"https://cdn.deepseek.com/site-icons/gxdfz.org.cn","site_name":"广西数字方志馆","query_indexes":[0]},{"url":"https://www.gxdag.org.cn/show/142/3576","title":"广西档案信息网 - 看旧图说南宁(展览馆、解放路、广西大学)(图)","snippet":"三”布告通告后被6912部队和“联指”组织围歼。 解放路,以前叫做德邻路。文化大革命时期被“4。22”造反派组织称之为“解放区”,“4。22”的很多主要组织都盘踞在这里。有“青年近卫军”...","cite_index":null,"published_at":1329753600.0,"site_icon":"https://cdn.deepseek.com/site-icons/gxdag.org.cn","site_name":"广西壮族自治区档案馆","query_indexes":[0]},{"url":"http://bbs.rauz.net.cn/archiver/tid-5626.html","title":"[原创]一段往事 两个冤魂[僚人家园]——壮族、布依族、岱侬族共同论坛★Riuzmingz 僚人人物","snippet":"

文革开始,两人被定为历史反革命分子。1968年春的某一天被红卫兵揪斗,因一人倾向“联指”,另一人偏向“4.22”(联指:广西革命造反派联合指挥部,矛头指向武振南书记;“4.22”...","cite_index":null,"published_at":1091980800.0,"site_icon":"https://cdn.deepseek.com/site-icons/rauz.net.cn","site_name":"僚人家园","query_indexes":[0]},{"url":"https://www.semanticscholar.org/author/%E6%9D%8E%E6%9E%9C%E6%B2%B3/2033988088","title":"李果河 | Semantic Scholar","snippet":"“文革”时,广西红卫兵和各地群众因为“支持韦国清”还是“打倒韦国清”,出现分歧,分裂成势不两立的两大派。一派叫广西“无产阶级革命派联合指挥部”(简称广西“联指”),另一派叫“广西革命造反派4·22指挥部”(简称广西“4·22”)。","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/semanticscholar.org","site_name":"Semantic Scholar","query_indexes":[0]},{"url":"http://bbs.rauz.net.cn/forum.php?mod=viewthread&tid=5626&page=1&authorid=1463","title":"[原创]一段往事 两个冤魂","snippet":"1968年春的某一天被红卫兵揪斗,因一人倾向“联指”,另一人偏向“4.22”(联指:广西革命造反派联合指挥部,矛头指向武振南书记;“4.22”...","cite_index":null,"published_at":1091980800.0,"site_icon":"https://cdn.deepseek.com/site-icons/rauz.net.cn","site_name":"僚人家园","query_indexes":[0]},{"url":"https://kiss.kstudy.com/Detail/Ar?key=3975781","title":"周邊에서 본 文化大革命 -廣西 派閥鬪爭을 중심으로- - 중국사연구 - 중국사학회 - KISS","snippet":"最終\"4·22\"成爲主要攻擊目標的廣西文革的悲劇也許是按照預告的程序進行的。在北京進行的接見政治中,慫恿協商及矛盾的權宜之計也許是在戰爭和革命的矛盾中不可避免的。但是...","cite_index":null,"published_at":1664553600.0,"site_icon":"https://cdn.deepseek.com/site-icons/kstudy.com","site_name":"학술논문검색사이트 KISS","query_indexes":[0]}]} - -data: {"p":"response/fragments/-1/status","v":"FINISHED"} - -data: {"p":"response/fragments/-7/results","v":[{"url":"http://www.zzdsw.com/yjcg/ztyj/201603/t20160331_1521613.html","title":"专题研究- 枣庄党史史志网","snippet":"发生在1967年7月25日至26日,是文化大革命中枣庄地区两派不同观点群众,发生的较大规模的武斗流血事件,也是枣庄地区两派群众组织派性激化的表现。","cite_index":null,"published_at":1459353600.0,"site_icon":"https://cdn.deepseek.com/site-icons/zzdsw.com","site_name":"zzdsw.com","query_indexes":[0]},{"url":"http://zx.hanzhong.gov.cn/hzzxwz/thhm/201605/t20160510_330408.shtml","title":"“文革”期间勉县武斗和“六·二”事件","snippet":"比较大的武斗事件21起,死亡85人(其中打死47人,武器走火死亡12人,武斗汽车肇事死亡13人,武斗中触电死亡1人,打死无辜群众3人...","cite_index":null,"published_at":1618588800.0,"site_icon":"https://cdn.deepseek.com/site-icons/hanzhong.gov.cn","site_name":"汉中市人民政府","query_indexes":[0]},{"url":"https://www.hinews.cn:443/news/system/2009/10/16/010588314_01.shtml","title":"“文革”进入狂热时期 “一月风暴”影响海南","snippet":"派性的斗争使得局势变得错综复杂,越来越多的群众参与进来,形成了群众组织派性斗争。由于斗争激烈,扰乱了夺权运动的大方向,并给生产带来了严重影响。海南农垦也被牵扯进来了。","cite_index":null,"published_at":1255622400.0,"site_icon":"https://cdn.deepseek.com/site-icons/hinews.cn","site_name":"南海网","query_indexes":[0]},{"url":"https://www.songjiang.gov.cn/sjsz/sjds/zggcdsjdfsz/files/basic-html/page202.html","title":"Page 202 - 中国共产党松江历史图志","snippet":"革委会成立不久,造反组织在对待 县革委会的态度上,分裂成“炮轰” 和“支持”两大派(即“轰派”和 “支派”)。于是两派斗争不断, 且愈演愈烈,最后导致 1967 年发","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/songjiang.gov.cn","site_name":"上海市松江区人民政府","query_indexes":[0]}]} - -data: {"p":"response/fragments/-7/status","v":"FINISHED"} - -data: {"p":"response/fragments/-6/results","v":[{"url":"https://www.163.com/dy/article/ILUUJUG00552PRXR.html","title":"陈毅受到批斗,她被派去“摸情况”,得到指示:运去英雄不自由","snippet":"王力的这次讲话也被称为“八七讲话”,在讲话中,王力对造反派讲,批斗陈毅的大方向是对的,同时,他还表示要改变外交部的现有制度,并声称谁都可以当外交部部长,鼓励他们批斗陈毅。当时陈毅担任外交部部长...","cite_index":null,"published_at":1702483200.0,"site_icon":"https://cdn.deepseek.com/site-icons/163.com","site_name":"网易","query_indexes":[0]},{"url":"http://epaper.voc.com.cn/wczm/html/2012-08/30/content_552828.htm?div=-1","title":"王季范解诗精准判时势-----文萃报数字报刊平台","snippet":"王力在讲话中,称外交部“运动阻力太大”,鼓吹要夺外交部的权,矛头直指外交部长陈毅。 王力的讲话发表后,我们外交部的“保陈派”们要弄清毛泽东对王力讲话的态度,便派出王海容去毛泽东那里“摸情况”。","cite_index":null,"published_at":1346256000.0,"site_icon":"https://cdn.deepseek.com/site-icons/voc.com.cn","site_name":"三湘都市报","query_indexes":[0]},{"url":"http://zhouenlai.people.cn/BIG5/n1/2021/0107/c409117-31992422-4.html","title":"揭秘\"救火隊長\"周恩來如何在\"文革\"中苦撐危局【4】","snippet":"造反派一陣“訴苦”后,王力開講了:“外交部運動阻力大?外交唬人嘛!別人不能干,了不起?這麼神秘,隻有少數專家才能干,你這個外交就這麼難?紅衛兵就不能干外交?","cite_index":null,"published_at":1609948800.0,"site_icon":"https://cdn.deepseek.com/site-icons/people.cn","site_name":"周恩来纪念网","query_indexes":[0]},{"url":"http://m.people.cn/n4/0/2017/0724/c677-9387667-2_2.html","title":"67年周恩来一针见血斥责何人的行径“是剜心战术”","snippet":"8月7日,谢富治与王力两人各发表了一次著名的造反讲话。王力在接见外交系统造反派代表时,煽动要向外交部夺权,打倒陈毅,引起了外交部的大动乱,酿成了火烧英国驻华代办处的恶劣外交事件。这个讲话被称为“王八七讲话”。","cite_index":null,"published_at":1500825600.0,"site_icon":"https://cdn.deepseek.com/site-icons/people.cn","site_name":"人民网","query_indexes":[0]}]} - -data: {"p":"response/fragments/-6/status","v":"FINISHED"} - -data: {"p":"response/fragments/-2/results","v":[{"url":"http://copyright.hktdc.com/tc/book-info.aspx?book_id=823d7c975971437786dc61e0f69bcdf8","title":"版權交易專區","snippet":"本書是湖南長沙一名工人造反派有關他在文革期間活動的回憶錄。書內釐清了一些史實和理論上的疑難問題,如所謂湘江風雷搶武器問題以及這一湖南最大造反派組織是如何興起的、湖南文革中武鬥經過以及一些目前忽略的造反派組織等等。這些資料...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/hktdc.com","site_name":"HKTDC.com","query_indexes":[0]},{"url":"https://www.4305.cn/w/l8z6r48080t","title":"血色记忆:1967年武冈的\"武斗\"","snippet":"武冈大武斗的起因是二派大组织:武工联,湘武工联,这是文化大革命”出现的群众组织,对于谁是“造反派”,谁是“保守派”争论不休。各造反派组织之间从维护他们各自的利益出发...","cite_index":null,"published_at":1593964800.0,"site_icon":"https://cdn.deepseek.com/site-icons/4305.cn","site_name":"武冈人网","query_indexes":[0]}]} - -data: {"p":"response/fragments/-2/status","v":"FINISHED"} - -data: {"p":"response/fragments/-3/results","v":[{"url":"https://www.bilibili.com/video/BV1ynNGe1EAo/?spm_id_from=333.1387.homepage.video_card.click","title":"“羊城革命浪潮起,封《红卫报》遭市民抵制,陶铸倒台后,以中大为核心的造反派夺得省市大权,但与地总,红总爆发冲突”——【广州文革地方史】(1)_哔哩哔哩_bilibili","snippet":"起,封《红卫报》遭市民抵制,陶铸倒台后,以中大为核心的造反派夺得省市大权,但与地总,红总爆发冲突”——【广州文革地方史】(1)_哔哩哔哩_bilibili | # “羊城革命浪潮起...","cite_index":null,"published_at":1738944000.0,"site_icon":"https://cdn.deepseek.com/site-icons/bilibili.com","site_name":"B站","query_indexes":[0]},{"url":"https://baijiahao.baidu.com/s?id=1743184378359987823","title":"梁锦棠:曾任广东革委会副主任,1977年被审查,后来被开除党籍","snippet":"并且成为了以工人群体为主的“红总”派的头头。“红总”派是广东的造反派之一,主要头目都是工厂的工人,这个组织属于“总派”,也被称为“东风派”,他们与广东的另一派造反组织“红旗派”是死对头,双方爆发过大规模“武斗”,造成了非常恶劣的影响。","cite_index":null,"published_at":1662393600.0,"site_icon":"https://cdn.deepseek.com/site-icons/baidu.com","site_name":"百家号","query_indexes":[0]},{"url":"http://bbs.hasea.com/thread-277771-2-1.html","title":"文革铁路专题章-火车头篇","snippet":"1967年3月25日,广州铁路分局革命委员会在广州地区率先成立,称之\"广州响起了第一声春雷\",属于\"东风\"派系.\"广铁总司\"属\"红旗\"派系(简称旗派).因话题与贴子主题不切,因此不便在此作更多的探讨啦~","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/hasea.com","site_name":"海子铁路网","query_indexes":[0]},{"url":"http://opinion.haiwainet.cn/n/2014/0429/c345416-20582980-2.html","title":"港片中的内地人:从人蛇大圈仔到优才蝗虫 【2】","snippet":"《省港旗兵》的大陆劫匪头目“东哥”被设定为文革时在广州以武斗闻名的“旗派”红卫兵 大圈仔(代表电影《省港旗兵》系列)","cite_index":null,"published_at":1398700800.0,"site_icon":"https://cdn.deepseek.com/site-icons/haiwainet.cn","site_name":"海外网评","query_indexes":[0]},{"url":"https://m.sohu.com/n/472559456/","title":"820武斗纪事","snippet":"他们说东风派把旗派的人抓住后,“当作活靶,一批批进行枪杀”。广医红旗八二○大血案调查组声称,旗派在这次武斗中,伤亡惨重,“公路两旁遍是尸体”,光是在医院抢救无效死亡的就有十多人,受伤住院的50多人(见《广医红旗》1967年8月25日)。但有的文章更惊人地声称...","cite_index":null,"published_at":1478534400.0,"site_icon":"https://cdn.deepseek.com/site-icons/sohu.com","site_name":"手机搜狐网","query_indexes":[0]},{"url":"https://weibo.com/ttarticle/p/show?id=2309405005800564457890","title":"【回忆录】一九六八种种烦忧","snippet":"8月,广州两派组织各占据广州部分高楼做武斗据点,东风派占据长堤省总工会等,红旗派占据长堤爱群大厦等。暨大318小分队属旗派,就按上面指令入驻爱群大厦。省总在沿江西路149号...","cite_index":null,"published_at":1708876800.0,"site_icon":"https://cdn.deepseek.com/site-icons/weibo.com","site_name":"微博","query_indexes":[0]},{"url":"http://www.gzyzxyh.com/thread-14455-1-1.html","title":"广州日报事件 (下) [复制链接]","snippet":"省军管会阵脚已动,不得不节节退让。当天晚上,由驻中大工作组宣布中大红旗八三一是左派组织;八一战斗兵团被拘捕的九名成员,在此之前也获得释放。经过这么一番反复...","cite_index":null,"published_at":1389369600.0,"site_icon":"https://cdn.deepseek.com/site-icons/gzyzxyh.com","site_name":"gzyzxyh.com","query_indexes":[0]},{"url":"http://opinion.haiwainet.cn/BIG5/n/2014/0429/c345416-20582980-2.html","title":"港片中的內地人:從人蛇大圈仔到優才蝗虫 【2】","snippet":"《省港旗兵》的大陸劫匪頭目“東哥”被設定為文革時在廣州以武斗聞名的“旗派”紅衛兵 大圈仔(代表電影《省港旗兵》系列)","cite_index":null,"published_at":1398700800.0,"site_icon":"https://cdn.deepseek.com/site-icons/haiwainet.cn","site_name":"海外网评","query_indexes":[0]},{"url":"http://www.qec.com.cn/bD2021/02/02/2552.shtml","title":"周继能:迟到的忏悔——我对不起老师们","snippet":"“文革”开始时我就读于广州市第17中66届高三(1)班,与同学们一样,响应毛主席号召,积极参加“文革”,参加了学校的“战斗兵团”,与广州市的“毛泽东主义红卫兵”属同一派,与广州市另一红卫兵组织“红旗”严重对立。1968年7月...","cite_index":null,"published_at":1612195200.0,"site_icon":"https://cdn.deepseek.com/site-icons/qec.com.cn","site_name":"Qec","query_indexes":[0]}]} - -data: {"p":"response/fragments/-3/status","v":"FINISHED"} - -data: {"p":"response/has_pending_fragment","v":true} - -data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":35,"type":"THINK","content":"搜索结果","elapsed_secs":null,"references":[],"stage_id":4}]},{"p":"has_pending_fragment","o":"SET","v":false}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"提供了"} - -data: {"v":"大量"} - -data: {"v":"关于"} - -data: {"v":"派"} - -data: {"v":"系"} - -data: {"v":"斗争的"} - -data: {"v":"案例"} - -data: {"v":"。"} - -data: {"v":"我将"} - -data: {"v":"基于"} - -data: {"v":"这些"} - -data: {"v":"资料"} - -data: {"v":","} - -data: {"v":"组织"} - -data: {"v":"回答"} - -data: {"v":","} - -data: {"v":"涵盖"} - -data: {"v":"基层"} - -data: {"v":"群众"} - -data: {"v":"派"} - -data: {"v":"系"} - -data: {"v":"斗争"} - -data: {"v":"、"} - -data: {"v":"高层"} - -data: {"v":"权力"} - -data: {"v":"博弈"} - -data: {"v":"、"} - -data: {"v":"历史"} - -data: {"v":"人物的"} - -data: {"v":"复杂性"} - -data: {"v":"以及"} - -data: {"v":"获取"} - -data: {"v":"更多"} - -data: {"v":"信息的"} - -data: {"v":"建议"} - -data: {"v":"。"} - -data: {"v":"回答"} - -data: {"v":"将"} - -data: {"v":"强调"} - -data: {"v":"这些"} - -data: {"v":"事例"} - -data: {"v":"的"} - -data: {"v":"细节"} - -data: {"v":"性和"} - -data: {"v":"复杂性"} - -data: {"v":","} - -data: {"v":"并"} - -data: {"v":"避免"} - -data: {"v":"单一"} - -data: {"v":"评价"} - -data: {"v":"立场"} - -data: {"v":"。"} - -data: {"p":"response/fragments/-1/elapsed_secs","o":"SET","v":1.260543306} - -data: {"p":"response/fragments","o":"APPEND","v":[{"id":36,"type":"RESPONSE","content":"历史","references":[],"stage_id":4}]} - -data: {"p":"response/fragments/-1/content","v":"老师"} - -data: {"v":"您好"} - -data: {"v":"。"} - -data: {"v":"要在"} - -data: {"v":"历史"} - -data: {"v":"教材"} - -data: {"v":"的"} - -data: {"v":"概括"} - -data: {"v":"性"} - -data: {"v":"叙述"} - -data: {"v":"之外"} - -data: {"v":","} - -data: {"v":"捕捉"} - -data: {"v":"到"} - -data: {"v":"文革"} - -data: {"v":"期间"} - -data: {"v":"派"} - -data: {"v":"系"} - -data: {"v":"斗争的"} - -data: {"v":"细节"} - -data: {"v":"与"} - -data: {"v":"复杂性"} - -data: {"v":","} - -data: {"v":"我们可以"} - -data: {"v":"从"} - -data: {"v":"一些"} - -data: {"v":"具体"} - -data: {"v":"的历史"} - -data: {"v":"切"} - -data: {"v":"面"} - -data: {"v":"入手"} - -data: {"v":"。"} - -data: {"v":"这些"} - -data: {"v":"案例"} - -data: {"v":"揭示了"} - -data: {"v":"在"} - -data: {"v":"宏大"} - -data: {"v":"叙事"} - -data: {"v":"之下"} - -data: {"v":","} - -data: {"v":"个体"} - -data: {"v":"与"} - -data: {"v":"群体的"} - -data: {"v":"矛盾"} - -data: {"v":"、"} - -data: {"v":"挣扎"} - -data: {"v":"与"} - -data: {"v":"博弈"} - -data: {"v":"。\n\n"} - -data: {"v":"###"} - -data: {"v":" 🎭"} - -data: {"v":" "} - -data: {"v":"派"} - -data: {"v":"系"} - -data: {"v":"众生"} - -data: {"v":"相"} - -data: {"v":":"} - -data: {"v":"群众"} - -data: {"v":"组织的"} - -data: {"v":"分化"} - -data: {"v":"与"} - -data: {"v":"斗争"} - -data: {"v":"\n"} - -data: {"v":"随着"} - -data: {"v":"运动的"} - -data: {"v":"深入"} - -data: {"v":","} - -data: {"v":"原本"} - -data: {"v":"统一的"} - -data: {"v":"“"} - -data: {"v":"革命"} - -data: {"v":"群众"} - -data: {"v":"”"} - -data: {"v":"迅速"} - -data: {"v":"分化"} - -data: {"v":"。"} - -data: {"v":"草"} - -data: {"v":"根"} - -data: {"v":"层面的"} - -data: {"v":"派"} - -data: {"v":"系"} - -data: {"v":"斗争"} - -data: {"v":"往往"} - -data: {"v":"比"} - -data: {"v":"高层"} - -data: {"v":"博弈"} - -data: {"v":"更加"} - -data: {"v":"残酷"} - -data: {"v":","} - -data: {"v":"其"} - -data: {"v":"复杂性"} - -data: {"v":"在于"} - -data: {"v":","} - -data: {"v":"斗争的"} - -data: {"v":"双方"} - -data: {"v":"都"} - -data: {"v":"坚信"} - -data: {"v":"自己"} - -data: {"v":"代表了"} - -data: {"v":"真正的"} - -data: {"v":"“"} - -data: {"v":"革命"} - -data: {"v":"方向"} - -data: {"v":"”"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:0]"},{"p":"references","o":"SET","v":[{"id":12,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"红"} - -data: {"v":"卫"} - -data: {"v":"兵"} - -data: {"v":"“"} - -data: {"v":"天"} - -data: {"v":"派"} - -data: {"v":"”"} - -data: {"v":"与"} - -data: {"v":"“"} - -data: {"v":"地"} - -data: {"v":"派"} - -data: {"v":"”"} - -data: {"v":"**"} - -data: {"v":":"} - -data: {"v":"北京"} - -data: {"v":"的红"} - -data: {"v":"卫"} - -data: {"v":"兵"} - -data: {"v":"因"} - -data: {"v":"所属"} - -data: {"v":"大学"} - -data: {"v":"不同"} - -data: {"v":"分"} - -data: {"v":"化为"} - -data: {"v":"“"} - -data: {"v":"天"} - -data: {"v":"派"} - -data: {"v":"”("} - -data: {"v":"北"} - -data: {"v":"航"} - -data: {"v":"“"} - -data: {"v":"红旗"} - -data: {"v":"”)"} - -data: {"v":"和"} - -data: {"v":"“"} - -data: {"v":"地"} - -data: {"v":"派"} - -data: {"v":"”("} - -data: {"v":"地质"} - -data: {"v":"“"} - -data: {"v":"东方"} - -data: {"v":"红"} - -data: {"v":"”)"} - -data: {"v":"等"} - -data: {"v":"派"} - -data: {"v":"系"} - -data: {"v":","} - -data: {"v":"并在"} - -data: {"v":"“"} - -data: {"v":"中央"} - -data: {"v":"文革"} - -data: {"v":"”"} - -data: {"v":"的"} - -data: {"v":"挑"} - -data: {"v":"动"} - -data: {"v":"下"} - -data: {"v":"矛盾"} - -data: {"v":"激"} - -data: {"v":"化"} - -data: {"v":","} - -data: {"v":"各"} - -data: {"v":"派的"} - -data: {"v":"“"} - -data: {"v":"五大"} - -data: {"v":"领袖"} - -data: {"v":"”"} - -data: {"v":"在当时"} - -data: {"v":"极具"} - -data: {"v":"影响力"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:1]"},{"p":"references","v":[{"id":30,"type":"TOOL_SEARCH"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"广州"} - -data: {"v":"“"} - -data: {"v":"东风"} - -data: {"v":"派"} - -data: {"v":"”"} - -data: {"v":"与"} - -data: {"v":"“"} - -data: {"v":"红旗"} - -data: {"v":"派"} - -data: {"v":"”"} - -data: {"v":"**"} - -data: {"v":":"} - -data: {"v":"广州"} - -data: {"v":"以"} - -data: {"v":"“"} - -data: {"v":"红"} - -data: {"v":"总"} - -data: {"v":"”"} - -data: {"v":"为代表的"} - -data: {"v":"“"} - -data: {"v":"东风"} - -data: {"v":"派"} - -data: {"v":"”"} - -data: {"v":"和"} - -data: {"v":"以"} - -data: {"v":"“"} - -data: {"v":"广"} - -data: {"v":"铁"} - -data: {"v":"总"} - -data: {"v":"司"} - -data: {"v":"”"} - -data: {"v":"为代表的"} - -data: {"v":"“"} - -data: {"v":"红旗"} - -data: {"v":"派"} - -data: {"v":"”"} - -data: {"v":"势"} - -data: {"v":"同"} - -data: {"v":"水火"} - -data: {"v":"。"} - -data: {"v":"196"} - -data: {"v":"7"} - -data: {"v":"年"} - -data: {"v":"8"} - -data: {"v":"月"} - -data: {"v":"20"} - -data: {"v":"日"} - -data: {"v":"爆"} - -data: {"v":"发的"} - -data: {"v":"“"} - -data: {"v":"820"} - -data: {"v":"武"} - -data: {"v":"斗"} - -data: {"v":"”"} - -data: {"v":"中"} - -data: {"v":","} - -data: {"v":"双方"} - -data: {"v":"在"} - -data: {"v":"市区"} - -data: {"v":"利用"} - -data: {"v":"高楼"} - -data: {"v":"作为"} - -data: {"v":"据点"} - -data: {"v":"枪"} - -data: {"v":"战"} - -data: {"v":","} - -data: {"v":"造成"} - -data: {"v":"大量"} - -data: {"v":"伤亡"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:2]"},{"p":"references","v":[{"id":32,"type":"TOOL_SEARCH"}]}]} - -data: {"v":[{"p":"content","o":"APPEND","v":"[reference:3]"},{"p":"references","v":[{"id":32,"type":"TOOL_SEARCH"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"上海"} - -data: {"v":"“"} - -data: {"v":"工"} - -data: {"v":"总"} - -data: {"v":"司"} - -data: {"v":"”"} - -data: {"v":"的权力"} - -data: {"v":"之争"} - -data: {"v":"**"} - -data: {"v":":“"} - -data: {"v":"工"} - -data: {"v":"总"} - -data: {"v":"司"} - -data: {"v":"”"} - -data: {"v":"内部"} - -data: {"v":"同样"} - -data: {"v":"存在"} - -data: {"v":"派"} - -data: {"v":"系"} - -data: {"v":"斗争"} - -data: {"v":"。"} - -data: {"v":"早期"} - -data: {"v":"领导人"} - -data: {"v":"潘"} - -data: {"v":"国"} - -data: {"v":"平"} - -data: {"v":"与"} - -data: {"v":"后来"} - -data: {"v":"成为"} - -data: {"v":"“"} - -data: {"v":"四人"} - -data: {"v":"帮"} - -data: {"v":"”"} - -data: {"v":"骨干"} - -data: {"v":"的王"} - -data: {"v":"洪"} - -data: {"v":"文"} - -data: {"v":"之间"} - -data: {"v":","} - -data: {"v":"为"} - -data: {"v":"争夺"} - -data: {"v":"组织领导"} - -data: {"v":"权"} - -data: {"v":"存在"} - -data: {"v":"复杂"} - -data: {"v":"博弈"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:4]"},{"p":"references","v":[{"id":31,"type":"TOOL_SEARCH"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"广西"} - -data: {"v":"“"} - -data: {"v":"联"} - -data: {"v":"指"} - -data: {"v":"”"} - -data: {"v":"与"} - -data: {"v":"“"} - -data: {"v":"4"} - -data: {"v":"·"} - -data: {"v":"22"} - -data: {"v":"”"} - -data: {"v":"**"} - -data: {"v":":"} - -data: {"v":"广西"} - -data: {"v":"群众"} - -data: {"v":"组织"} - -data: {"v":"围绕"} - -data: {"v":"“"} - -data: {"v":"支持"} - -data: {"v":"韦"} - -data: {"v":"国"} - -data: {"v":"清"} - -data: {"v":"”"} - -data: {"v":"还是"} - -data: {"v":"“"} - -data: {"v":"打倒"} - -data: {"v":"韦"} - -data: {"v":"国"} - -data: {"v":"清"} - -data: {"v":"”"} - -data: {"v":"分裂"} - -data: {"v":"为"} - -data: {"v":"“"} - -data: {"v":"联"} - -data: {"v":"指"} - -data: {"v":"”"} - -data: {"v":"和"} - -data: {"v":"“"} - -data: {"v":"4"} - -data: {"v":"·"} - -data: {"v":"22"} - -data: {"v":"”"} - -data: {"v":"两大"} - -data: {"v":"派"} - -data: {"v":","} - -data: {"v":"两"} - -data: {"v":"派"} - -data: {"v":"冲突"} - -data: {"v":"中"} - -data: {"v":"甚至"} - -data: {"v":"出现了"} - -data: {"v":"武装"} - -data: {"v":"民兵"} - -data: {"v":"直接"} - -data: {"v":"袭击"} - -data: {"v":"另一"} - -data: {"v":"派"} - -data: {"v":"据点"} - -data: {"v":"的情况"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:5]"},{"p":"references","v":[{"id":34,"type":"TOOL_SEARCH"}]}]} - -data: {"v":[{"p":"content","o":"APPEND","v":"[reference:6]"},{"p":"references","v":[{"id":34,"type":"TOOL_SEARCH"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"各地"} - -data: {"v":"武"} - -data: {"v":"斗"} - -data: {"v":"的"} - -data: {"v":"惨"} - -data: {"v":"烈"} - -data: {"v":"代价"} - -data: {"v":"**"} - -data: {"v":":"} - -data: {"v":"派"} - -data: {"v":"系"} - -data: {"v":"斗争"} - -data: {"v":"常"} - -data: {"v":"升级"} - -data: {"v":"为"} - -data: {"v":"武"} - -data: {"v":"斗"} - -data: {"v":"。"} - -data: {"v":"例如"} - -data: {"v":"在"} - -data: {"v":"陕西"} - -data: {"v":"勉"} - -data: {"v":"县"} - -data: {"v":","} - -data: {"v":"从"} - -data: {"v":"196"} - -data: {"v":"7"} - -data: {"v":"年"} - -data: {"v":"9"} - -data: {"v":"月至"} - -data: {"v":"196"} - -data: {"v":"8"} - -data: {"v":"年"} - -data: {"v":"9"} - -data: {"v":"月"} - -data: {"v":"的一年"} - -data: {"v":"中"} - -data: {"v":","} - -data: {"v":"武"} - -data: {"v":"斗"} - -data: {"v":"共"} - -data: {"v":"导致"} - -data: {"v":"85"} - -data: {"v":"人死亡"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:7]"},{"p":"references","v":[{"id":15,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":";"} - -data: {"v":"河北"} - -data: {"v":"武"} - -data: {"v":"安"} - -data: {"v":"的两"} - -data: {"v":"派"} - -data: {"v":"对立"} - -data: {"v":"导致了"} - -data: {"v":"抢"} - -data: {"v":"枪"} - -data: {"v":"、"} - -data: {"v":"武"} - -data: {"v":"斗"} - -data: {"v":","} - -data: {"v":"致"} - -data: {"v":"近"} - -data: {"v":"200"} - -data: {"v":"人死亡"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:8]"},{"p":"references","v":[{"id":14,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":";"} - -data: {"v":"山东"} - -data: {"v":"枣"} - -data: {"v":"庄"} - -data: {"v":"的"} - -data: {"v":"“"} - -data: {"v":"七"} - -data: {"v":"二"} - -data: {"v":"五"} - -data: {"v":"”"} - -data: {"v":"事件"} - -data: {"v":"更是"} - -data: {"v":"有"} - -data: {"v":"400"} - -data: {"v":"0"} - -data: {"v":"余名"} - -data: {"v":"武装"} - -data: {"v":"农民"} - -data: {"v":"参与"} - -data: {"v":","} - -data: {"v":"造成"} - -data: {"v":"26"} - -data: {"v":"人"} - -data: {"v":"当场"} - -data: {"v":"被打"} - -data: {"v":"死"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:9]"},{"p":"references","v":[{"id":13,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n\n"} - -data: {"v":"###"} - -data: {"v":" 🔥"} - -data: {"v":" "} - -data: {"v":"失控"} - -data: {"v":"的"} - -data: {"v":"漩涡"} - -data: {"v":":"} - -data: {"v":"高"} - -data: {"v":"层的"} - -data: {"v":"博弈"} - -data: {"v":"与"} - -data: {"v":"基层"} - -data: {"v":"的"} - -data: {"v":"共振"} - -data: {"v":"\n"} - -data: {"v":"中央"} - -data: {"v":"高"} - -data: {"v":"层的"} - -data: {"v":"权力"} - -data: {"v":"斗争"} - -data: {"v":","} - -data: {"v":"通过与"} - -data: {"v":"地方"} - -data: {"v":"派"} - -data: {"v":"系"} - -data: {"v":"结合"} - -data: {"v":","} - -data: {"v":"迅速"} - -data: {"v":"演"} - -data: {"v":"变为"} - -data: {"v":"全国"} - -data: {"v":"性的"} - -data: {"v":"动荡"} - -data: {"v":"。\n\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"中央"} - -data: {"v":"文革"} - -data: {"v":"的"} - -data: {"v":"“"} - -data: {"v":"点火"} - -data: {"v":"”"} - -data: {"v":"**"} - -data: {"v":":"} - -data: {"v":"196"} - -data: {"v":"7"} - -data: {"v":"年"} - -data: {"v":"2"} - -data: {"v":"月"} - -data: {"v":"后"} - -data: {"v":",“"} - -data: {"v":"中央"} - -data: {"v":"文革"} - -data: {"v":"”"} - -data: {"v":"取代"} - -data: {"v":"了"} - -data: {"v":"中央"} - -data: {"v":"政治"} - -data: {"v":"局的"} - -data: {"v":"职能"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:10]"},{"p":"references","v":[{"id":5,"type":"TOOL_SEARCH"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。"} - -data: {"v":"其"} - -data: {"v":"成员"} - -data: {"v":"王"} - -data: {"v":"力"} - -data: {"v":"、"} - -data: {"v":"关"} - -data: {"v":"锋"} - -data: {"v":"、"} - -data: {"v":"戚"} - -data: {"v":"本"} - -data: {"v":"禹"} - -data: {"v":"等人"} - -data: {"v":"四处"} - -data: {"v":"煽"} - -data: {"v":"动"} - -data: {"v":","} - -data: {"v":"如"} - -data: {"v":"王"} - -data: {"v":"力"} - -data: {"v":"196"} - -data: {"v":"7"} - -data: {"v":"年"} - -data: {"v":"8"} - -data: {"v":"月"} - -data: {"v":"7"} - -data: {"v":"日的"} - -data: {"v":"“"} - -data: {"v":"八"} - -data: {"v":"七"} - -data: {"v":"讲话"} - -data: {"v":"”"} - -data: {"v":"直接"} - -data: {"v":"煽"} - -data: {"v":"动"} - -data: {"v":"造反"} - -data: {"v":"派"} - -data: {"v":"夺取"} - -data: {"v":"外交部"} - -data: {"v":"权力"} - -data: {"v":","} - -data: {"v":"矛"} - -data: {"v":"头"} - -data: {"v":"直"} - -data: {"v":"指"} - -data: {"v":"陈"} - -data: {"v":"毅"} - -data: {"v":","} - -data: {"v":"直接"} - -data: {"v":"引发了"} - -data: {"v":"火烧"} - -data: {"v":"英国"} - -data: {"v":"驻"} - -data: {"v":"华"} - -data: {"v":"代办"} - -data: {"v":"处的"} - -data: {"v":"严重"} - -data: {"v":"外交"} - -data: {"v":"事件"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:11]"},{"p":"references","v":[{"id":16,"type":"TOOL_OPEN"}]}]} - -data: {"v":[{"p":"content","o":"APPEND","v":"[reference:12]"},{"p":"references","v":[{"id":29,"type":"TOOL_SEARCH"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"“"} - -data: {"v":"反复"} - -data: {"v":"旧"} - -data: {"v":"”"} - -data: {"v":"运动的"} - -data: {"v":"荒"} - -data: {"v":"诞"} - -data: {"v":"**"} - -data: {"v":":"} - -data: {"v":"196"} - -data: {"v":"8"} - -data: {"v":"年的"} - -data: {"v":"“"} - -data: {"v":"反复"} - -data: {"v":"旧"} - -data: {"v":"”"} - -data: {"v":"运动"} - -data: {"v":"体现了"} - -data: {"v":"运动的"} - -data: {"v":"荒谬"} - -data: {"v":"性"} - -data: {"v":"。"} - -data: {"v":"一些"} - -data: {"v":"在"} - -data: {"v":"革"} - -data: {"v":"委会"} - -data: {"v":"任职"} - -data: {"v":"的"} - -data: {"v":"“"} - -data: {"v":"走"} - -data: {"v":"资"} - -data: {"v":"派"} - -data: {"v":"”"} - -data: {"v":"被"} - -data: {"v":"重新"} - -data: {"v":"打倒"} - -data: {"v":","} - -data: {"v":"江苏"} - -data: {"v":"建"} - -data: {"v":"湖"} - -data: {"v":"县"} - -data: {"v":"三"} - -data: {"v":"主任"} - -data: {"v":"被"} - -data: {"v":"关"} - -data: {"v":"进"} - -data: {"v":"“"} - -data: {"v":"学习"} - -data: {"v":"班"} - -data: {"v":"”"} - -data: {"v":"残酷"} - -data: {"v":"斗争"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:13]"},{"p":"references","v":[{"id":12,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":","} - -data: {"v":"许多"} - -data: {"v":"原本"} - -data: {"v":"执行"} - -data: {"v":"政策的"} - -data: {"v":"解放军"} - -data: {"v":"官兵"} - -data: {"v":","} - -data: {"v":"也"} - -data: {"v":"因"} - -data: {"v":"形势"} - -data: {"v":"变化"} - -data: {"v":"成为"} - -data: {"v":"整治"} - -data: {"v":"对象"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:14]"},{"p":"references","v":[{"id":12,"type":"TOOL_OPEN"}]}]} - -data: {"v":[{"p":"content","o":"APPEND","v":"[reference:15]"},{"p":"references","v":[{"id":12,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"地方"} - -data: {"v":"派"} - -data: {"v":"系"} - -data: {"v":"与国家"} - -data: {"v":"机器的"} - -data: {"v":"共振"} - -data: {"v":"**"} - -data: {"v":":"} - -data: {"v":"地方"} - -data: {"v":"派"} - -data: {"v":"系"} - -data: {"v":"斗争"} - -data: {"v":"时常"} - -data: {"v":"获得"} - -data: {"v":"国家"} - -data: {"v":"机器的"} - -data: {"v":"支持"} - -data: {"v":"。"} - -data: {"v":"如"} - -data: {"v":"山东"} - -data: {"v":"的王"} - -data: {"v":"效"} - -data: {"v":"禹"} - -data: {"v":"直接"} - -data: {"v":"动用"} - -data: {"v":"省"} - -data: {"v":"革"} - -data: {"v":"委会"} - -data: {"v":"和"} - -data: {"v":"军区"} - -data: {"v":"名义"} - -data: {"v":"支持"} - -data: {"v":"一派"} - -data: {"v":","} - -data: {"v":"派"} - -data: {"v":"飞机"} - -data: {"v":"散发"} - -data: {"v":"通告"} - -data: {"v":"、"} - -data: {"v":"在全省"} - -data: {"v":"设"} - -data: {"v":"卡"} - -data: {"v":"抓捕"} - -data: {"v":"对立"} - -data: {"v":"派"} - -data: {"v":"群众"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:16]"},{"p":"references","v":[{"id":13,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n\n"} - -data: {"v":"###"} - -data: {"v":" 🎭"} - -data: {"v":" "} - -data: {"v":"人性的"} - -data: {"v":"困境"} - -data: {"v":":"} - -data: {"v":"浮"} - -data: {"v":"沉"} - -data: {"v":"人物的"} - -data: {"v":"复杂"} - -data: {"v":"面向"} - -data: {"v":"\n"} - -data: {"v":"教材"} - -data: {"v":"之外"} - -data: {"v":","} - -data: {"v":"许多"} - -data: {"v":"历史"} - -data: {"v":"人物"} - -data: {"v":"并非"} - -data: {"v":"脸"} - -data: {"v":"谱"} - -data: {"v":"化的"} - -data: {"v":"角色"} - -data: {"v":","} - -data: {"v":"其"} - -data: {"v":"沉"} - -data: {"v":"浮"} - -data: {"v":"展现了"} - -data: {"v":"人性的"} - -data: {"v":"复杂"} - -data: {"v":"与"} - -data: {"v":"时代的"} - -data: {"v":"吊"} - -data: {"v":"诡"} - -data: {"v":"。\n\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"陈"} - -data: {"v":"伯"} - -data: {"v":"达"} - -data: {"v":"**"} - -data: {"v":":"} - -data: {"v":"他曾"} - -data: {"v":"任"} - -data: {"v":"“"} - -data: {"v":"中央"} - -data: {"v":"文革"} - -data: {"v":"小组"} - -data: {"v":"”"} - -data: {"v":"组长"} - -data: {"v":","} - -data: {"v":"是"} - -data: {"v":"“"} - -data: {"v":"文革"} - -data: {"v":"”"} - -data: {"v":"的关键"} - -data: {"v":"人物"} - -data: {"v":","} - -data: {"v":"但在"} - -data: {"v":"198"} - -data: {"v":"1"} - -data: {"v":"年被"} - -data: {"v":"判"} - -data: {"v":"刑"} - -data: {"v":"18"} - -data: {"v":"年"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:17]"},{"p":"references","v":[{"id":23,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。"} - -data: {"v":"他的"} - -data: {"v":"复杂性"} - -data: {"v":"在于"} - -data: {"v":","} - -data: {"v":"他也"} - -data: {"v":"曾"} - -data: {"v":"做过"} - -data: {"v":"贡献"} - -data: {"v":","} - -data: {"v":"如"} - -data: {"v":"194"} - -data: {"v":"8"} - -data: {"v":"年在"} - -data: {"v":"城南"} - -data: {"v":"庄"} - -data: {"v":"冒"} - -data: {"v":"死"} - -data: {"v":"救护"} - -data: {"v":"过"} - -data: {"v":"毛泽东"} - -data: {"v":","} - -data: {"v":"这一"} - -data: {"v":"事迹"} - -data: {"v":"因"} - -data: {"v":"他"} - -data: {"v":"后来"} - -data: {"v":"成为"} - -data: {"v":"主"} - -data: {"v":"犯"} - -data: {"v":"而在"} - -data: {"v":"许多"} - -data: {"v":"回忆"} - -data: {"v":"录"} - -data: {"v":"中被"} - -data: {"v":"隐"} - -data: {"v":"去"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:18]"},{"p":"references","v":[{"id":22,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"陶"} - -data: {"v":"铸"} - -data: {"v":"**"} - -data: {"v":":"} - -data: {"v":"这位"} - -data: {"v":"中南"} - -data: {"v":"局"} - -data: {"v":"第一书记"} - -data: {"v":"在"} - -data: {"v":"“"} - -data: {"v":"文革"} - -data: {"v":"”"} - -data: {"v":"初期"} - -data: {"v":"被"} - -data: {"v":"提拔"} - -data: {"v":"为"} - -data: {"v":"政治局"} - -data: {"v":"常委"} - -data: {"v":","} - -data: {"v":"但"} - -data: {"v":"仅"} - -data: {"v":"4"} - -data: {"v":"个"} - -data: {"v":"多月"} - -data: {"v":"后"} - -data: {"v":"就被"} - -data: {"v":"江"} - -data: {"v":"青"} - -data: {"v":"等人"} - -data: {"v":"公开"} - -data: {"v":"打倒"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:19]"},{"p":"references","v":[{"id":17,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。"} - -data: {"v":"其"} - -data: {"v":"悲剧"} - -data: {"v":"在于"} - -data: {"v":","} - -data: {"v":"他曾"} - -data: {"v":"因"} - -data: {"v":"在"} - -data: {"v":"“"} - -data: {"v":"七"} - -data: {"v":"千人"} - -data: {"v":"大会"} - -data: {"v":"”"} - -data: {"v":"上"} - -data: {"v":"反对"} - -data: {"v":"刘少"} - -data: {"v":"奇的"} - -data: {"v":"“"} - -data: {"v":"人"} - -data: {"v":"祸"} - -data: {"v":"”"} - -data: {"v":"论"} - -data: {"v":"而"} - -data: {"v":"得到"} - -data: {"v":"毛泽东"} - -data: {"v":"赏识"} - -data: {"v":","} - -data: {"v":"最终"} - -data: {"v":"却被"} - -data: {"v":"更"} - -data: {"v":"激"} - -data: {"v":"进的"} - -data: {"v":"浪潮"} - -data: {"v":"吞噬"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:20]"},{"p":"references","v":[{"id":17,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"李"} - -data: {"v":"雪"} - -data: {"v":"峰"} - -data: {"v":"**"} - -data: {"v":":"} - -data: {"v":"他"} - -data: {"v":"因"} - -data: {"v":"在"} - -data: {"v":"“"} - -data: {"v":"四"} - -data: {"v":"清"} - -data: {"v":"”"} - -data: {"v":"运动中"} - -data: {"v":"与"} - -data: {"v":"刘少"} - -data: {"v":"奇"} - -data: {"v":"有"} - -data: {"v":"分歧"} - -data: {"v":"被"} - -data: {"v":"毛泽东"} - -data: {"v":"重用"} - -data: {"v":"为"} - -data: {"v":"北京"} - -data: {"v":"市委"} - -data: {"v":"第一书记"} - -data: {"v":","} - -data: {"v":"但在"} - -data: {"v":"政治"} - -data: {"v":"风暴"} - -data: {"v":"中"} - -data: {"v":"难以"} - -data: {"v":"自"} - -data: {"v":"保"} - -data: {"v":","} - -data: {"v":"其"} - -data: {"v":"命运"} - -data: {"v":"同样"} - -data: {"v":"坎坷"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:21]"},{"p":"references","v":[{"id":17,"type":"TOOL_OPEN"}]}]} - -data: {"v":[{"p":"content","o":"APPEND","v":"[reference:22]"},{"p":"references","v":[{"id":17,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"赵"} - -data: {"v":"健"} - -data: {"v":"民"} - -data: {"v":"**"} - -data: {"v":":"} - -data: {"v":"时任"} - -data: {"v":"云南"} - -data: {"v":"省委"} - -data: {"v":"书记"} - -data: {"v":"的"} - -data: {"v":"赵"} - -data: {"v":"健"} - -data: {"v":"民"} - -data: {"v":","} - -data: {"v":"在京"} - -data: {"v":"西"} - -data: {"v":"宾馆"} - -data: {"v":"会议上"} - -data: {"v":"据"} - -data: {"v":"理"} - -data: {"v":"力争"} - -data: {"v":","} - -data: {"v":"却被"} - -data: {"v":"康"} - -data: {"v":"生"} - -data: {"v":"以"} - -data: {"v":"历史"} - -data: {"v":"问题"} - -data: {"v":"为由"} - -data: {"v":"诬"} - -data: {"v":"陷"} - -data: {"v":"为"} - -data: {"v":"“"} - -data: {"v":"叛"} - -data: {"v":"徒"} - -data: {"v":"”,"} - -data: {"v":"被"} - -data: {"v":"非法"} - -data: {"v":"关"} - -data: {"v":"押"} - -data: {"v":"长达"} - -data: {"v":"八年"} - -data: {"v":"。"} - -data: {"v":"更"} - -data: {"v":"令人"} - -data: {"v":"痛"} - -data: {"v":"心"} - -data: {"v":"的是"} - -data: {"v":","} - -data: {"v":"此"} - -data: {"v":"冤"} - -data: {"v":"案"} - -data: {"v":"导致"} - -data: {"v":"云南省"} - -data: {"v":"超过"} - -data: {"v":"170"} - -data: {"v":"00"} - -data: {"v":"人被"} - -data: {"v":"牵连"} - -data: {"v":"致死"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:23]"},{"p":"references","v":[{"id":20,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"周恩来"} - -data: {"v":"**"} - -data: {"v":":"} - -data: {"v":"在"} - -data: {"v":"极度"} - -data: {"v":"混乱"} - -data: {"v":"的局面"} - -data: {"v":"中"} - -data: {"v":","} - -data: {"v":"他"} - -data: {"v":"尽力"} - -data: {"v":"“"} - -data: {"v":"苦"} - -data: {"v":"撑"} - -data: {"v":"危"} - -data: {"v":"局"} - -data: {"v":"”。"} - -data: {"v":"在"} - -data: {"v":"中央"} - -data: {"v":"文革"} - -data: {"v":"挑"} - -data: {"v":"动"} - -data: {"v":"外交部"} - -data: {"v":"夺"} - -data: {"v":"权"} - -data: {"v":"时"} - -data: {"v":","} - -data: {"v":"他"} - -data: {"v":"一方面"} - -data: {"v":"严厉"} - -data: {"v":"批评"} - -data: {"v":","} - -data: {"v":"另一方面"} - -data: {"v":"指挥"} - -data: {"v":"部队"} - -data: {"v":"营"} - -data: {"v":"救"} - -data: {"v":"被困"} - -data: {"v":"人员"} - -data: {"v":","} - -data: {"v":"试图"} - -data: {"v":"挽回"} - -data: {"v":"失控"} - -data: {"v":"的外"} - -data: {"v":"交"} - -data: {"v":"局面"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:24]"},{"p":"references","v":[{"id":16,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"许"} - -data: {"v":"广"} - -data: {"v":"平"} - -data: {"v":"**"} - -data: {"v":":"} - -data: {"v":"在"} - -data: {"v":"山东"} - -data: {"v":"大学"} - -data: {"v":"批判"} - -data: {"v":"校长"} - -data: {"v":"成"} - -data: {"v":"仿"} - -data: {"v":"吾"} - -data: {"v":"时"} - -data: {"v":","} - -data: {"v":"许"} - -data: {"v":"广"} - -data: {"v":"平"} - -data: {"v":"作为"} - -data: {"v":"鲁迅"} - -data: {"v":"夫人"} - -data: {"v":","} - -data: {"v":"她的"} - -data: {"v":"态度"} - -data: {"v":"和"} - -data: {"v":"抉择"} - -data: {"v":"也"} - -data: {"v":"反映了"} - -data: {"v":"那个"} - -data: {"v":"时代"} - -data: {"v":"知识分子"} - -data: {"v":"面临的"} - -data: {"v":"复杂"} - -data: {"v":"处境"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:25]"},{"p":"references","v":[{"id":24,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"纳"} - -data: {"v":"西"} - -data: {"v":"族"} - -data: {"v":"老"} - -data: {"v":"村"} - -data: {"v":"长的"} - -data: {"v":"二"} - -data: {"v":"叔"} - -data: {"v":"**"} - -data: {"v":":"} - -data: {"v":"一位"} - -data: {"v":"回乡"} - -data: {"v":"军人"} - -data: {"v":","} - -data: {"v":"仅"} - -data: {"v":"因"} - -data: {"v":"将"} - -data: {"v":"有"} - -data: {"v":"领导人"} - -data: {"v":"签"} - -data: {"v":"名的"} - -data: {"v":"证明"} - -data: {"v":"藏在"} - -data: {"v":"鞋"} - -data: {"v":"底"} - -data: {"v":","} - -data: {"v":"便被"} - -data: {"v":"诬"} - -data: {"v":"陷"} - -data: {"v":"为"} - -data: {"v":"“"} - -data: {"v":"第三"} - -data: {"v":"党"} - -data: {"v":"”"} - -data: {"v":"分子"} - -data: {"v":","} - -data: {"v":"这"} - -data: {"v":"则"} - -data: {"v":"个案"} - -data: {"v":"反映了"} - -data: {"v":"那个"} - -data: {"v":"年代"} - -data: {"v":"荒"} - -data: {"v":"诞"} - -data: {"v":"的"} - -data: {"v":"罪名"} - -data: {"v":"如何"} - -data: {"v":"轻易"} - -data: {"v":"地"} - -data: {"v":"摧毁"} - -data: {"v":"普通"} - -data: {"v":"人的生活"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:26]"},{"p":"references","v":[{"id":21,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n\n"} - -data: {"v":"您"} - -data: {"v":"需要"} - -data: {"v":"更多"} - -data: {"v":"原始"} - -data: {"v":"材料"} - -data: {"v":","} - -data: {"v":"可以"} - -data: {"v":"查阅"} - -data: {"v":"叶"} - -data: {"v":"永"} - -data: {"v":"烈的"} - -data: {"v":"《"} - -data: {"v":"“"} - -data: {"v":"四人"} - -data: {"v":"帮"} - -data: {"v":"”"} - -data: {"v":"兴"} - -data: {"v":"亡"} - -data: {"v":"》("} - -data: {"v":"基于"} - -data: {"v":"大量"} - -data: {"v":"亲"} - -data: {"v":"历"} - -data: {"v":"者"} - -data: {"v":"采访"} - -data: {"v":")"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:27]"},{"p":"references","v":[{"id":22,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"、"} - -data: {"v":"丁"} - -data: {"v":"龙"} - -data: {"v":"嘉"} - -data: {"v":"与"} - -data: {"v":"听"} - -data: {"v":"雨的"} - -data: {"v":"《"} - -data: {"v":"康"} - -data: {"v":"生"} - -data: {"v":"与"} - -data: {"v":"“"} - -data: {"v":"赵"} - -data: {"v":"健"} - -data: {"v":"民"} - -data: {"v":"冤"} - -data: {"v":"案"} - -data: {"v":"》"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:28]"},{"p":"references","v":[{"id":18,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":","} - -data: {"v":"以及"} - -data: {"v":"各地"} - -data: {"v":"政协"} - -data: {"v":"编"} - -data: {"v":"撰"} - -data: {"v":"的"} - -data: {"v":"文史"} - -data: {"v":"资料"} - -data: {"v":"。"} - -data: {"v":"您"} - -data: {"v":"可以根据"} - -data: {"v":"教学"} - -data: {"v":"重点"} - -data: {"v":","} - -data: {"v":"从"} - -data: {"v":"这些"} - -data: {"v":"具体"} - -data: {"v":"案例"} - -data: {"v":"中"} - -data: {"v":"筛选"} - -data: {"v":"细节"} - -data: {"v":","} - -data: {"v":"帮助学生"} - -data: {"v":"理解"} - -data: {"v":"那段"} - -data: {"v":"历史的"} - -data: {"v":"复杂"} - -data: {"v":"肌"} - -data: {"v":"理"} - -data: {"v":"。"} - -data: {"p":"response/fragments","v":[{"id":37,"type":"TIP","content":"本回答由 AI 生成,内容仅供参考,请仔细甄别。","style":"WARNING","hide_on_wip":true}]} - -data: {"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":1269},{"p":"quasi_status","v":"FINISHED"}]} - -: - -data: {"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 -data: {} - -event: update_session -data: {"updated_at":1775320133.058034} - -event: close -data: {"click_behavior":"none","auto_resume":false} - diff --git a/tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260404/meta.json b/tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260404/meta.json deleted file mode 100644 index 8f14549..0000000 --- a/tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260404/meta.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "sample_id": "guangzhou-weather-reasoner-search-20260404", - "captured_at_utc": "2026-04-04T16:01:27Z", - "request": { - "model": "deepseek-v4-pro-search", - "stream": true, - "messages": [ - { - "role": "user", - "content": "广州天气" - } - ] - }, - "capture": { - "label": "deepseek_completion", - "url": "https://chat.deepseek.com/api/v0/chat/completion", - "status_code": 200, - "response_bytes": 37651, - "contains_reference_markers": true, - "reference_marker_count": 13, - "contains_finished_token": true, - "finished_token_count": 19 - } -} \ No newline at end of file diff --git a/tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260404/upstream.stream.sse b/tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260404/upstream.stream.sse deleted file mode 100644 index a1a8187..0000000 --- a/tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260404/upstream.stream.sse +++ /dev/null @@ -1,686 +0,0 @@ -event: ready -data: {"request_message_id":1,"response_message_id":2,"model_type":"default"} - -event: update_session -data: {"updated_at":1775318472.209828} - -data: {"v":{"response":{"message_id":2,"parent_id":1,"model":"","role":"ASSISTANT","thinking_enabled":true,"ban_edit":false,"ban_regenerate":false,"status":"WIP","incomplete_message":null,"accumulated_token_usage":0,"files":[],"feedback":null,"inserted_at":1775318472.20079,"search_enabled":true,"fragments":[{"id":2,"type":"THINK","content":"用户","elapsed_secs":null,"references":[],"stage_id":1}],"conversation_mode":"DEEP_SEARCH","has_pending_fragment":false,"auto_continue":false,"search_triggered":true}}} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"想知道"} - -data: {"v":"广州"} - -data: {"v":"的"} - -data: {"v":"天气"} - -data: {"v":"。"} - -data: {"v":"今天是"} - -data: {"v":"202"} - -data: {"v":"6"} - -data: {"v":"年"} - -data: {"v":"4"} - -data: {"v":"月"} - -data: {"v":"5"} - -data: {"v":"日"} - -data: {"v":"。"} - -data: {"v":"我需要"} - -data: {"v":"提供"} - -data: {"v":"广州"} - -data: {"v":"当前的"} - -data: {"v":"天气"} - -data: {"v":"情况"} - -data: {"v":"以及"} - -data: {"v":"未来"} - -data: {"v":"几"} - -data: {"v":"天的"} - -data: {"v":"预报"} - -data: {"v":"。"} - -data: {"v":"为了"} - -data: {"v":"全面"} - -data: {"v":"获取"} - -data: {"v":"信息"} - -data: {"v":","} - -data: {"v":"我将"} - -data: {"v":"同时"} - -data: {"v":"进行"} - -data: {"v":"多项"} - -data: {"v":"搜索"} - -data: {"v":","} - -data: {"v":"以"} - -data: {"v":"覆盖"} - -data: {"v":"不同"} - -data: {"v":"可能的"} - -data: {"v":"天气"} - -data: {"v":"信息来源"} - -data: {"v":"和"} - -data: {"v":"查询"} - -data: {"v":"方式"} - -data: {"v":"。"} - -data: {"p":"response/fragments/-1/elapsed_secs","o":"SET","v":1.426722585} - -data: {"p":"response/has_pending_fragment","v":true} - -data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":3,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"广州 天气 2026-04-05"}],"results":[],"stage_id":1},{"id":4,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"广州 天气预报 2026年4月"}],"results":[],"stage_id":1},{"id":5,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"广州 天气 2026-04-05 每小时"}],"results":[],"stage_id":1},{"id":6,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"广州 空气质量 2026-04-05"}],"results":[],"stage_id":1},{"id":7,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"2026-04-05 广州 天气 预报"}],"results":[],"stage_id":1},{"id":8,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"广州 天气 2026年4月5日"}],"results":[],"stage_id":1}]},{"p":"has_pending_fragment","o":"SET","v":false}]} - -data: {"p":"response/fragments/-5/results","o":"SET","v":[{"url":"https://news.qq.com/rain/a/20260401A06GQ200?suid=&media_id=","title":"4月广州仍有冷空气“搞事情”,3次弱冷空气+4次降水轮番登场","snippet":"预计4月广州将有4次降水过程,大致出现在4月3日—6日(中到大雨)、4月12日—14日(中到大雨,局部暴雨)、4月17日—21日(大雨,局部暴雨)、4月25日—28日(大雨到暴雨)。","cite_index":null,"published_at":1774972800.0,"site_icon":"https://cdn.deepseek.com/site-icons/qq.com","site_name":"QQ News","query_indexes":[0]},{"url":"https://www.sohu.com/a/1004262788_162758?scm=10001.325_13-325_13.0.0-0-0-0-0.5_1334&spm=smpc.channel_248.block3_308_NDdFbm_1_fd.10.1775093958609Tqfgwxd_324","title":"最新预测:广州4月预计有3次冷空气、4次降水过程","snippet":"根据气象部门预测,4月广州将遭遇3次冷空气过程。另外,4月将有4次降水过程,预计当月广州降雨量将比常年偏少1~2成 ... 预计4月广州将有4次降水过程,大致出现在4月3日—6日(中到大雨)、4月12日—14日(中到大雨,局部暴雨)、4月17日—21日(大雨 ... 4月25日—28日(大雨到暴雨)。","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/sohu.com","site_name":"Sohu","query_indexes":[0]},{"url":"https://www.sohu.com/a/1004726143_162758?scm=10001.325_13-325_13.0.0-0-0-0-0.5_1334&spm=smpc.channel_248.block3_308_NDdFbm_1_fd.1.1775175954306LbCjz6t_324","title":"大暴雨、12级大风、冰雹!广州未来7天都“泡汤”","snippet":"广州市气象台预计,4月3日—4日广州有中到大雨局部暴雨,4月5日—6日有大到暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地冰雹等强对流天气。4月7日起降水减弱。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/sohu.com","site_name":"Sohu","query_indexes":[0]},{"url":"https://www.nmc.cn/publish/forecast/AGD/guangzhou.html","title":"广州-天气预报","snippet":"04/04 周六 20℃ 多云 无持续风向 微风 04/05 周日 暴雨 ... 04/06 周一 ... 04/07 周二 雷阵雨","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},{"url":"https://www.163.com/dy/article/KPG99VPJ0530JPVV.html","title":"下一轮强降水时间定了!广州天气,即将反转","snippet":"广州市气象台预计,4月2日广州有雷阵雨。4月3日—4日有中到大雨局部暴雨,并伴有短时强降水、8~10级局部11~12级短时大风、冰雹等强对流天气。4月5日—6日仍有明显降水伴强对流过程。","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/163.com","site_name":"网易","query_indexes":[0]},{"url":"https://news.qq.com/rain/a/20260401A06VNB00?suid=&media_id=","title":"4月广州或有4次集中降水过程,6月中下旬或迎“初台”","snippet":"根据《广州市2026年4月气候趋势预测》,预计4月广州各区月平均气温22.6~25.0°C,全市平均气温23.1~23.6°C,较近30年(1996-2025年,下同)同期(22.6°C)偏高0.5~1.0°C...","cite_index":null,"published_at":1774972800.0,"site_icon":"https://cdn.deepseek.com/site-icons/qq.com","site_name":"QQ News","query_indexes":[0]},{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/7day.html","title":"广州天气预报7天天气","snippet":"2026-04-05 (星期日) 大雨转雷阵雨 ... 2026-04-06 (星期一) 雷阵雨转多云 24℃ ~ 28℃ 南风转微风 3-4级转<3级","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},{"url":"https://daohuangpuqu.gz-cmc.com/pages/2026/04/04/69ada0e552a245e5b0859954b7ab4cf3.html","title":"广东,要下暴雨、大暴雨了!","snippet":"广州市气象台预计,4月4日广州有中到大雨局部暴雨,4月5日有大到暴雨,并伴有雷电、短时强降水、8~10级短时大风、局地冰雹等强对流天气,4月6日白天起降水逐渐减弱。","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/gz-cmc.com","site_name":"广州日报新花城","query_indexes":[0]},{"url":"https://tianqi.moji.com/forecast15/china/guangdong/guangzhou","title":"【广州市天气预报15天】_广州市天气预报15天查询 - 预报查询","snippet":"15天预报 ... - 周五 雷阵雨 28° 20° - 周六 中雨 ... - 周日 暴雨 25° 20° - 周一 中雨 30° 23° - 周二 雷阵雨","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/moji.com","site_name":"墨迹天气","query_indexes":[0]},{"url":"https://news.dayoo.com/guangzhou/202604/04/139995_54944458.htm","title":"广州6日雨势减弱,随后迎来一段平稳天气","snippet":"广州市气象台预计,4月5日有大到暴雨,并伴有雷电、短时强降水、6~8级短时大风等强对流天气,4月6日雨势减弱,4月7日转以多云间阴天为主。","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/dayoo.com","site_name":"大洋网","query_indexes":[0]}]} - -data: {"p":"response/fragments/-5/status","v":"FINISHED"} - -data: {"p":"response/fragments/-3/results","v":[{"url":"https://www.accuweather.com/zh/cn/guangzhou/102255/air-quality-index/102255","title":"广州, 广东省, 中国空气质量指数 | AccuWeather","snippet":"空气质量量表 ... 空气质量对大多数人来说是可接受的。然而 ... 广州的当前空气质量 极好 危险 极好 有利 差 不健康 很不健康 危险 ## 24小时空气质量预报","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/accuweather.com","site_name":"AccuWeather","query_indexes":[0]},{"url":"http://sthjj.gz.gov.cn/zwgk/hjgb/","title":"广州市生态环境局网站","snippet":"2026年1月广州市环境空气质量状况 2026-02-24 广州市白云区2024年度生态环境统计公报 2026-01-27 2025年12月广州市环境空气质量状况 2026-01-22 广州市2024年排放源统计公报","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/gz.gov.cn","site_name":"广州市生态环境局","query_indexes":[0]},{"url":"http://cenews.com.cn/news.html?aid=1770007","title":"从“广州蓝”到“全运蓝”,广州以治气硬功守牢生态颜值——中国环境网","snippet":"2025年,广州AQI达标率达93.4%,连续3年超九成;PM2.5平均浓度降至21.5微克/立方米,在国家中心城市中保持最优。尤其在十五运会举办期间...","cite_index":null,"published_at":1771948800.0,"site_icon":"https://cdn.deepseek.com/site-icons/cenews.com.cn","site_name":"中国环境网","query_indexes":[0]},{"url":"http://www.zx24.com/aqi/guangdong-guangzhou.html","title":"广州空气污染:实时空气质量指数(AQI)","snippet":"空气质量指数 | 污染级别 | 对健康的影响 | 建议采取措施 ---|---|---|--- 0-50 | 优 | 空气质量令人满意,基本无空气污染,对健康没有危害 | 各类人群可多参加户外活动,多呼吸一下清新的空气。","cite_index":null,"published_at":1774713600.0,"site_icon":"https://cdn.deepseek.com/site-icons/zx24.com","site_name":"zx24.com","query_indexes":[0]},{"url":"http://vlife.src.weather.com.cn/maqi/101280101.shtml","title":"广州空气质量指数AQI--中国天气网","snippet":"优 良 轻度 中度 重度 严重 PM2.5 PM10 CO NO2 SO2 O3 单位:μg/m3(CO为mg/m3)","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com.cn","site_name":"天气网","query_indexes":[0]},{"url":"http://www.yuexiu.gov.cn/yxdt/rdzt/zdlyxxgk/hjbh/kqhjxx/","title":"广州市越秀区人民政府门户网站","snippet":"2026年1月广州市环境空气质量状况 2026-03-04 2025年12月广州市环境空气质量状况 2026-01-27 2025年11月广州市环境空气质量状况 2025-12-16 2025年10月广州市环境空气质量状况","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/yuexiu.gov.cn","site_name":"广州市越秀区人民政府","query_indexes":[0]},{"url":"http://pc.weathercn.com/weather/102255/?partner=20000010%d0%93%d2%93%d0%96%e2%80%99%d0%93%d3%ae%d0%92%d0%88%d0%93%d2%93%d0%b2%d2%96%d2%a1%d0%93%d1%9e%d0%b2%d2%96%d2%a1%d0%92%c2%ac%d0%93%d2%93%d0%b2%d2%96%d2%a1%d0%93%d3%ae%d0%bf%d2%9d%d2%aa10.hfaw&p_source=&p_type=jump&areatype=","title":"广州当前天气","snippet":"广州空气质量 39 优 0 50 100 150 200 300 500 空气很棒,快出门呼吸新鲜空气吧。 适宜外出","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/weathercn.com","site_name":"华风爱科气象科技","query_indexes":[0]},{"url":"https://weather.com/zh-CN/forecast/air-quality/l/37dd90a3a17d6f9942ef56a03373eaa185be2b88164dc31754a305d17d7646c9","title":"越秀区, 广州市, 广东省 空气质量预报 - The Weather Channel | weather.com","snippet":"今日空气质量- 越秀区, 广州市, 广东省 优秀 无健康影响。 主要污染物: PM2.5 (2.5 微米以下细颗粒物) 等级 优秀","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com","site_name":"The Weather Channel","query_indexes":[0]},{"url":"https://www.aqi.in/in/dashboard/china/guangdong-province/guangzhou/baiyun","title":"Baiyun Air Quality Index (AQI) : Real-Time Air Pollution","snippet":"The current real-time AQI (US) level in Baiyun is 58 (Moderate) due to recent rainfall by 0.01mm/hr. This was last updated 2026-03-05 08:01 (Local Time).","cite_index":null,"published_at":1772640000.0,"site_icon":"https://cdn.deepseek.com/site-icons/aqi.in","site_name":"AQI.in","query_indexes":[0]},{"url":"https://www.zq12369.com/environment?city=%e5%b9%bf%e5%b7%9e&tab=city","title":"真气网-关注环境空气质量|广州PM2.5查询|PM2.5实时查询|PM2.5雾霾地图|空气质量地图指数AQI和PM2.5预报查询","snippet":"AQI范围: 40~70 ### 首要污染物: O3,PM2.5 ### 出行建议: 极少数异常敏感人群应减少户外活动 ... AQI范围: 60~90","cite_index":null,"published_at":1774886400.0,"site_icon":"https://cdn.deepseek.com/site-icons/zq12369.com","site_name":"真气网","query_indexes":[0]}]} - -data: {"p":"response/fragments/-3/status","v":"FINISHED"} - -data: {"p":"response/fragments/-4/results","v":[{"url":"https://www.nmc.cn/publish/forecast/AGD/guangzhou.html","title":"广州-天气预报","snippet":"04/04 ... 04/05 周日 暴雨 ... 04/06 周一 中雨 ... 04/07 周二 雷阵雨 ... 04/08 周三","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},{"url":"https://www.weather.com.cn/weather/101280101.shtml","title":"广州天气预报,广州7天天气预报,广州15天天气预报,广州天气查询","snippet":"25℃/20℃ <3级 # 6日(后天) 中雨转多云 30℃/23℃ 3-4级转<3级 # 7日(周二) 雷阵雨转多云 29℃/24℃","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com.cn","site_name":"天气网","query_indexes":[0]},{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/7day.html","title":"广州天气预报7天天气","snippet":"2026-04-05 (星期日) 大雨转雷阵雨 21℃ ~ 25℃ 微风 <3级 | 日出06:16 日落18:43 2026-04-06 (星期一) 雷阵雨转多云 24℃ ~ 28℃","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},{"url":"https://windy.app/zh/forecast2/spot/7026063/%E5%B9%BF%E5%B7%9E%E5%8D%97%E9%A3%8E%E5%9C%BA","title":"广州南风场 风、浪和天气预报 — Windy.app","snippet":"Light wind – experience required (4.9 m/s) ... - Light wind – experience required (5.5 m/s) - Significant gusts forecast (11.1 m/s) - Heavy rain – dangerous conditions possible (>2 mm) ... 广州南风场, 天气预报及实时风图","cite_index":null,"published_at":1774800000.0,"site_icon":"https://cdn.deepseek.com/site-icons/windy.app","site_name":"WINDY.APP","query_indexes":[0]},{"url":"https://www.ip.cn/tianqi/guangdong/guangzhou/15day.html","title":"广州15天天气","snippet":"2026-04-05 天气 中雨转雷阵雨 21℃ ~ 24℃ 微风 <3级 日出06:16 日落18:43","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/ip.cn","site_name":"IP.cn","query_indexes":[0]},{"url":"https://www.sohu.com/a/1004726143_162758?scm=10001.325_13-325_13.0.0-0-0-0-0.5_1334&spm=smpc.channel_248.block3_308_NDdFbm_1_fd.1.1775175954306LbCjz6t_324","title":"大暴雨、12级大风、冰雹!广州未来7天都“泡汤”","snippet":"广州市气象台预计,4月3日—4日广州有中到大雨局部暴雨,4月5日—6日有大到暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地冰雹等强对流天气。4月7日起降水减弱。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/sohu.com","site_name":"Sohu","query_indexes":[0]},{"url":"https://www.fjndqx.com/china/guangdong-guangzhou/15tian.html","title":"15天广州天气预报","snippet":"2026-04-05 (星期日) 阴转小雨 21℃ ~ 27℃ 微风 <3级 | 日出06:16 日落18:43","cite_index":null,"published_at":1774800000.0,"site_icon":"https://cdn.deepseek.com/site-icons/fjndqx.com","site_name":"Fjndqx","query_indexes":[0]},{"url":"https://static.nfnews.com/content/202604/02/c12300320.html?colID=14&firstColID=59&enterColumnId=14","title":"下一轮强降水时间定了!广州天气,即将反转__南方+_南方plus","snippet":"周四起广州天气将再转不稳定 周五起 新一轮强降水天气过程将影响广州 并伴有短时强降水 8~10级局部11~12级短时大风","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/nfnews.com","site_name":"nfnews.com","query_indexes":[0]},{"url":"https://finance.sina.com.cn/wm/2026-04-02/doc-inhtakui4453746.shtml?cref=cj","title":"下一轮强降水时间定了!广州天气,即将反转","snippet":"周五起 新一轮强降水天气过程将影响广州 并伴有短时强降水 8~10级局部11~12级短时大风 局地小冰雹等强对流天气","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/sina.com.cn","site_name":"新浪财经","query_indexes":[0]},{"url":"https://static.nfnews.com/content/202604/02/c12302805.html","title":"雷雨大风+冰雹又要来了!清明假期广州将迎强对流天气__南方+_南方plus","snippet":"4月3日—4日,受高空槽、低层南风和切变线影响,广州有中到大雨局部暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地小冰雹等强对流天气。","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/nfnews.com","site_name":"nfnews.com","query_indexes":[0]}]} - -data: {"p":"response/fragments/-4/status","v":"FINISHED"} - -data: {"p":"response/fragments/-2/results","v":[{"url":"https://www.weather.com.cn/weather/101280101.shtml","title":"广州天气预报,广州7天天气预报,广州15天天气预报,广州天气查询","snippet":"5日(明天) ... 25℃/20℃ <3级 # 6日(后天) 中雨转多云 30℃/23℃ 3-4级转<3级 # 7日(周二) 雷阵雨转多云 29℃/24℃","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com.cn","site_name":"天气网","query_indexes":[0]},{"url":"https://www.nmc.cn/publish/forecast/AGD/guangzhou.html","title":"广州-天气预报","snippet":"04/04 ... 04/05 周日 暴雨 ... 04/06 周一 中雨 ... 04/07 周二 雷阵雨 ... 04/08 周三","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},{"url":"https://www.sohu.com/a/1004726143_162758?scm=10001.325_13-325_13.0.0-0-0-0-0.5_1334&spm=smpc.channel_248.block3_308_NDdFbm_1_fd.1.1775175954306LbCjz6t_324","title":"大暴雨、12级大风、冰雹!广州未来7天都“泡汤”","snippet":"广州市气象台预计,4月3日—4日广州有中到大雨局部暴雨,4月5日—6日有大到暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地冰雹等强对流天气。4月7日起降水减弱。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/sohu.com","site_name":"Sohu","query_indexes":[0]},{"url":"https://daohuangpuqu.gz-cmc.com/pages/2026/04/04/69ada0e552a245e5b0859954b7ab4cf3.html","title":"广东,要下暴雨、大暴雨了!","snippet":"广州市气象台预计,4月4日广州有中到大雨局部暴雨,4月5日有大到暴雨,并伴有雷电、短时强降水、8~10级短时大风、局地冰雹等强对流天气,4月6日白天起降水逐渐减弱。","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/gz-cmc.com","site_name":"广州日报新花城","query_indexes":[0]},{"url":"https://news.dayoo.com/guangzhou/202604/04/139995_54944458.htm","title":"广州6日雨势减弱,随后迎来一段平稳天气","snippet":"广州市气象台预计,4月5日有大到暴雨,并伴有雷电、短时强降水、6~8级短时大风等强对流天气,4月6日雨势减弱,4月7日转以多云间阴天为主。 广东具体预报...","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/dayoo.com","site_name":"大洋网","query_indexes":[0]},{"url":"https://www.gz.gov.cn/zt/fxbxaqdx/gg/content/post_10754327.html","title":"清明假期有大到暴雨","snippet":"4月3日—4日广州有中到大雨局部暴雨,4月5日—6日有大到暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地冰雹等强对流天气。4月7日起降水减弱。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/gz.gov.cn","site_name":"广州市人民政府门户网站","query_indexes":[0]},{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/7day.html","title":"广州天气预报7天天气","snippet":"2026-04-05 (星期日) 大雨转雷阵雨 21℃ ~ 25℃ 微风 <3级 | 日出06:16 日落18:43 2026-04-06 (星期一) 雷阵雨转多云","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},{"url":"https://guangzhoubaiyun.gz-cmc.com/pages/2026/04/02/e76e4847f66548089ac19908a65c473b.html","title":"清明假期,又开启“倒水”模式","snippet":"广州市气象台预计,4月2日广州有雷阵雨。4月3日—4日有中到大雨局部暴雨,并伴有短时强降水、8~10级局部11~12级短时大风、冰雹等强对流天气。4月5日—6日仍有明显降水伴强对流过程。","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/gz-cmc.com","site_name":"广州日报新花城","query_indexes":[0]},{"url":"https://www.accuweather.com/zh/cn/guangzhou/102255/weather-forecast/102255?type=locality","title":"广州, 广东省, 中国三天天气预报 | AccuWeather","snippet":"上午局部地区有雷雨;部分晴 ... 大部分多云,潮湿,有雷雨 大部分多云,潮湿,有阵雨或雷雨 ... 大部分多云,潮湿,有零星降雨和雷雨","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/accuweather.com","site_name":"AccuWeather","query_indexes":[0]}]} - -data: {"p":"response/fragments/-2/status","v":"FINISHED"} - -data: {"p":"response/fragments/-1/results","v":[{"url":"https://www.sohu.com/a/1004726143_162758?scm=10001.325_13-325_13.0.0-0-0-0-0.5_1334&spm=smpc.channel_248.block3_308_NDdFbm_1_fd.1.1775175954306LbCjz6t_324","title":"大暴雨、12级大风、冰雹!广州未来7天都“泡汤”","snippet":"广州市气象台预计,4月3日—4日广州有中到大雨局部暴雨,4月5日—6日有大到暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地冰雹等强对流天气。4月7日起降水减弱。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/sohu.com","site_name":"Sohu","query_indexes":[0]},{"url":"https://www.gz.gov.cn/zt/fxbxaqdx/gg/content/post_10754327.html","title":"清明假期有大到暴雨","snippet":"4月3日—4日广州有中到大雨局部暴雨,4月5日—6日有大到暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地冰雹等强对流天气。4月7日起降水减弱。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/gz.gov.cn","site_name":"广州市人民政府门户网站","query_indexes":[0]},{"url":"https://www.nmc.cn/publish/forecast/AGD/guangzhou.html","title":"广州-天气预报","snippet":"04/04 周六 20℃ ... 04/05 周日 暴雨 无持续风向 微风 25℃ 22℃ ... 04/06 周一 中雨 ... 04/07 周二","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},{"url":"https://www.weather.com.cn/weather/101280101.shtml","title":"广州天气预报,广州7天天气预报,广州15天天气预报,广州天气查询","snippet":"5日(明天) 暴雨转雷阵雨 25℃/20℃ <3级 # 6日(后天) 中雨转多云 30℃/23℃ 3-4级转<3级 # 7日(周二)","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com.cn","site_name":"天气网","query_indexes":[0]},{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/7day.html","title":"广州天气预报7天天气","snippet":"2026-04-05 (星期日) 大雨转雷阵雨 21℃ ~ 25℃ 微风 <3级 | 日出06:16 日落18:43 2026-04-06 (星期一) 雷阵雨转多云 24℃ ~ 28℃","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},{"url":"https://news.dayoo.com/guangzhou/202604/04/139995_54944458.htm","title":"广州6日雨势减弱,随后迎来一段平稳天气","snippet":"4月5日,粤北、粤东和珠三角市县有大雨到暴雨局部大暴雨,其余市县有(雷)阵雨;部分市县雷雨时伴有8~9级(局地10级)短时大风、短时强降水和局地小冰雹。","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/dayoo.com","site_name":"大洋网","query_indexes":[0]},{"url":"https://www.163.com/dy/article/KPLG595F0530JPVV.html","title":"广东,要下暴雨、大暴雨了!","snippet":"广州市气象台预计,4月4日广州有中到大雨局部暴雨,4月5日有大到暴雨,并伴有雷电、短时强降水、8~10级短时大风、局地冰雹等强对流天气,4月6日白天起降水逐渐减弱。","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/163.com","site_name":"网易","query_indexes":[0]},{"url":"http://gd.cma.gov.cn/","title":"广东省气象局","snippet":"19℃/10℃ 微风 03日(明日) 多云转晴 22℃/6℃ 3~4级 04日(后日) 晴转多云 19℃/8℃ 3~4级 05日(周日) 晴转多云 19℃/6℃","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/cma.gov.cn","site_name":"广东省气象局","query_indexes":[0]},{"url":"https://news.southcn.com/node_de9aedeec1/2a08783352.shtml","title":"今日降雨集中在广东南部,清明假期全省多地将遭遇强对流天气","snippet":"广州市区,4日,阴天到多云,有中到大雨局部暴雨,气温在20℃—25℃;5日,阴天,有大到暴雨,气温在21℃—25℃;6日,阴天,有中雨,气温在22℃—28℃。 气象部门提醒,广东已进入强对流和强降水多发季节,清明祭扫、外出踏青和高空作业需注意防范局地雷电、冰雹及短时大风引发的树木、厂房工棚、临时构筑物、户外广告牌倒塌等风险。","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/southcn.com","site_name":"南方网","query_indexes":[0]}]} - -data: {"p":"response/fragments/-1/status","v":"FINISHED"} - -data: {"p":"response/fragments/-6/results","v":[{"url":"https://www.nmc.cn/publish/forecast/AGD/guangzhou.html","title":"广州-天气预报","snippet":"04/04 ... 04/05 周日 暴雨 ... 04/06 周一 中雨 ... 04/07 周二 雷阵雨 ... 04/08 周三","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},{"url":"https://www.weather.com.cn/weather/101280101.shtml","title":"广州天气预报,广州7天天气预报,广州15天天气预报,广州天气查询","snippet":"5日(明天) ... 25℃/20℃ ... 6日(后天) 中雨转多云 30℃/23℃ 3-4级转<3级 # 7日(周二) 雷阵雨转多云 29℃/24℃","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com.cn","site_name":"天气网","query_indexes":[0]},{"url":"https://www.sohu.com/a/1004726143_162758?scm=10001.325_13-325_13.0.0-0-0-0-0.5_1334&spm=smpc.channel_248.block3_308_NDdFbm_1_fd.1.1775175954306LbCjz6t_324","title":"大暴雨、12级大风、冰雹!广州未来7天都“泡汤”","snippet":"广州市气象台预计,4月3日—4日广州有中到大雨局部暴雨,4月5日—6日有大到暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地冰雹等强对流天气。4月7日起降水减弱。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/sohu.com","site_name":"Sohu","query_indexes":[0]},{"url":"https://www.gz.gov.cn/zt/fxbxaqdx/gg/content/post_10754327.html","title":"清明假期有大到暴雨","snippet":"4月3日—4日广州有中到大雨局部暴雨,4月5日—6日有大到暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地冰雹等强对流天气。4月7日起降水减弱。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/gz.gov.cn","site_name":"广州市人民政府门户网站","query_indexes":[0]},{"url":"https://www.163.com/dy/article/KPLG595F0530JPVV.html","title":"广东,要下暴雨、大暴雨了!","snippet":"广州市气象台预计,4月4日广州有中到大雨局部暴雨,4月5日有大到暴雨,并伴有雷电、短时强降水、8~10级短时大风、局地冰雹等强对流天气,4月6日白天起降水逐渐减弱。","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/163.com","site_name":"网易","query_indexes":[0]},{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/7day.html","title":"广州天气预报7天天气","snippet":"2026-04-05 (星期日) 大雨转雷阵雨 21℃ ~ 25℃ 微风 <3级 | 日出06:16 日落18:43 2026-04-06 (星期一) 雷阵雨转多云 24℃ ~ 28℃","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},{"url":"https://news.dayoo.com/guangzhou/202604/04/139995_54944458.htm","title":"广州6日雨势减弱,随后迎来一段平稳天气","snippet":"广州市气象台预计,4月5日有大到暴雨,并伴有雷电、短时强降水、6~8级短时大风等强对流天气,4月6日雨势减弱,4月7日转以多云间阴天为主。 广东具体预报...","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/dayoo.com","site_name":"大洋网","query_indexes":[0]},{"url":"https://news.southcn.com/node_de9aedeec1/2a08783352.shtml","title":"今日降雨集中在广东南部,清明假期全省多地将遭遇强对流天气","snippet":"广州市区,4日,阴天到多云,有中到大雨局部暴雨,气温在20℃—25℃;5日,阴天,有大到暴雨,气温在21℃—25℃;6日,阴天,有中雨,气温在22℃—28℃。","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/southcn.com","site_name":"南方网","query_indexes":[0]},{"url":"https://guangzhoubaiyun.gz-cmc.com/pages/2026/04/02/e76e4847f66548089ac19908a65c473b.html","title":"清明假期,又开启“倒水”模式","snippet":"4月3日—4日有中到大雨局部暴雨,并伴有短时强降水、8~10级局部11~12级短时大风、冰雹等强对流天气。4月5日—6日仍有明显降水伴强对流过程。","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/gz-cmc.com","site_name":"广州日报新花城","query_indexes":[0]}]} - -data: {"p":"response/fragments/-6/status","v":"FINISHED"} - -data: {"p":"response/has_pending_fragment","v":true} - -data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":9,"type":"THINK","content":"搜索","elapsed_secs":null,"references":[],"stage_id":2}]},{"p":"has_pending_fragment","o":"SET","v":false}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"结果显示"} - -data: {"v":"了一些"} - -data: {"v":"相关"} - -data: {"v":"页面"} - -data: {"v":"。"} - -data: {"v":"为了"} - -data: {"v":"全面"} - -data: {"v":"获取"} - -data: {"v":"信息"} - -data: {"v":","} - -data: {"v":"我将"} - -data: {"v":"同时"} - -data: {"v":"打开"} - -data: {"v":"这些"} - -data: {"v":"页面"} - -data: {"v":"。"} - -data: {"p":"response/fragments/-1/elapsed_secs","o":"SET","v":0.682885751} - -data: {"p":"response/has_pending_fragment","v":true} - -: - -data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":10,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.nmc.cn/publish/forecast/AGD/guangzhou.html","title":"广州-天气预报","snippet":"04/04 ... 04/05 周日 暴雨 ... 04/06 周一 中雨 ... 04/07 周二 雷阵雨 ... 04/08 周三","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},"reference":{"id":3,"type":"TOOL_SEARCH"},"stage_id":2},{"id":11,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.weather.com.cn/weather/101280101.shtml","title":"广州天气预报,广州7天天气预报,广州15天天气预报,广州天气查询","snippet":"5日(明天) ... 25℃/20℃ ... 6日(后天) 中雨转多云 30℃/23℃ 3-4级转<3级 # 7日(周二) 雷阵雨转多云 29℃/24℃","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com.cn","site_name":"天气网","query_indexes":[0]},"reference":{"id":3,"type":"TOOL_SEARCH"},"stage_id":2},{"id":12,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/7day.html","title":"广州天气预报7天天气","snippet":"2026-04-05 (星期日) 大雨转雷阵雨 21℃ ~ 25℃ 微风 <3级 | 日出06:16 日落18:43 2026-04-06 (星期一) 雷阵雨转多云 24℃ ~ 28℃","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},"reference":{"id":3,"type":"TOOL_SEARCH"},"stage_id":2},{"id":13,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/7day.html","title":"广州天气预报7天天气","snippet":"2026-04-05 (星期日) 大雨转雷阵雨 ... 2026-04-06 (星期一) 雷阵雨转多云 24℃ ~ 28℃ 南风转微风 3-4级转<3级","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},"reference":{"id":4,"type":"TOOL_SEARCH"},"stage_id":2},{"id":14,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.ip.cn/tianqi/guangdong/guangzhou/15day.html","title":"广州15天天气","snippet":"2026-04-05 天气 中雨转雷阵雨 21℃ ~ 24℃ 微风 <3级 日出06:16 日落18:43","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/ip.cn","site_name":"IP.cn","query_indexes":[0]},"reference":{"id":5,"type":"TOOL_SEARCH"},"stage_id":2},{"id":15,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.accuweather.com/zh/cn/guangzhou/102255/weather-forecast/102255?type=locality","title":"广州, 广东省, 中国三天天气预报 | AccuWeather","snippet":"上午局部地区有雷雨;部分晴 ... 大部分多云,潮湿,有雷雨 大部分多云,潮湿,有阵雨或雷雨 ... 大部分多云,潮湿,有零星降雨和雷雨","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/accuweather.com","site_name":"AccuWeather","query_indexes":[0]},"reference":{"id":7,"type":"TOOL_SEARCH"},"stage_id":2},{"id":16,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.weather.com.cn/weather/101280101.shtml","title":"广州天气预报,广州7天天气预报,广州15天天气预报,广州天气查询","snippet":"5日(明天) 暴雨转雷阵雨 25℃/20℃ <3级 # 6日(后天) 中雨转多云 30℃/23℃ 3-4级转<3级 # 7日(周二)","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com.cn","site_name":"天气网","query_indexes":[0]},"reference":{"id":8,"type":"TOOL_SEARCH"},"stage_id":2},{"id":17,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/7day.html","title":"广州天气预报7天天气","snippet":"2026-04-05 (星期日) 大雨转雷阵雨 21℃ ~ 25℃ 微风 <3级 | 日出06:16 日落18:43 2026-04-06 (星期一) 雷阵雨转多云 24℃ ~ 28℃","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},"reference":{"id":8,"type":"TOOL_SEARCH"},"stage_id":2},{"id":18,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://news.southcn.com/node_de9aedeec1/2a08783352.shtml","title":"今日降雨集中在广东南部,清明假期全省多地将遭遇强对流天气","snippet":"广州市区,4日,阴天到多云,有中到大雨局部暴雨,气温在20℃—25℃;5日,阴天,有大到暴雨,气温在21℃—25℃;6日,阴天,有中雨,气温在22℃—28℃。","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/southcn.com","site_name":"南方网","query_indexes":[0]},"reference":{"id":3,"type":"TOOL_SEARCH"},"stage_id":2},{"id":19,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.accuweather.com/zh/cn/guangzhou/102255/air-quality-index/102255","title":"广州, 广东省, 中国空气质量指数 | AccuWeather","snippet":"空气质量量表 ... 空气质量对大多数人来说是可接受的。然而 ... 广州的当前空气质量 极好 危险 极好 有利 差 不健康 很不健康 危险 ## 24小时空气质量预报","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/accuweather.com","site_name":"AccuWeather","query_indexes":[0]},"reference":{"id":6,"type":"TOOL_SEARCH"},"stage_id":2},{"id":20,"type":"TOOL_OPEN","status":"WIP","result":{"url":"http://pc.weathercn.com/weather/102255/?partner=20000010%d0%93%d2%93%d0%96%e2%80%99%d0%93%d3%ae%d0%92%d0%88%d0%93%d2%93%d0%b2%d2%96%d2%a1%d0%93%d1%9e%d0%b2%d2%96%d2%a1%d0%92%c2%ac%d0%93%d2%93%d0%b2%d2%96%d2%a1%d0%93%d3%ae%d0%bf%d2%9d%d2%aa10.hfaw&p_source=&p_type=jump&areatype=","title":"广州当前天气","snippet":"广州空气质量 39 优 0 50 100 150 200 300 500 空气很棒,快出门呼吸新鲜空气吧。 适宜外出","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/weathercn.com","site_name":"华风爱科气象科技","query_indexes":[0]},"reference":{"id":6,"type":"TOOL_SEARCH"},"stage_id":2}]},{"p":"has_pending_fragment","o":"SET","v":false}]} - -data: {"p":"response/fragments/-7/status","o":"SET","v":"FINISHED"} - -data: {"p":"response/fragments/-6/status","v":"FINISHED"} - -data: {"p":"response/fragments/-3/status","v":"FINISHED"} - -data: {"p":"response/fragments/-11/status","v":"FINISHED"} - -data: {"p":"response/fragments/-10/status","v":"FINISHED"} - -data: {"p":"response/fragments/-5/status","v":"FINISHED"} - -data: {"p":"response/fragments/-4/status","v":"FINISHED"} - -data: {"p":"response/fragments/-9/status","v":"FINISHED"} - -data: {"p":"response/fragments/-8/status","v":"FINISHED"} - -data: {"p":"response/fragments/-2/status","v":"FINISHED"} - -data: {"p":"response/fragments/-1/status","v":"FINISHED"} - -data: {"p":"response/has_pending_fragment","v":true} - -data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":21,"type":"THINK","content":"这些","elapsed_secs":null,"references":[],"stage_id":3}]},{"p":"has_pending_fragment","o":"SET","v":false}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"搜索结果"} - -data: {"v":"提供了"} - -data: {"v":"广州"} - -data: {"v":"的"} - -data: {"v":"天气"} - -data: {"v":"信息"} - -data: {"v":"。"} - -data: {"v":"我将"} - -data: {"v":"组织"} - -data: {"v":"回答"} - -data: {"v":","} - -data: {"v":"提供"} - -data: {"v":"当前"} - -data: {"v":"天气"} - -data: {"v":"、"} - -data: {"v":"未来"} - -data: {"v":"几天"} - -data: {"v":"预报"} - -data: {"v":"、"} - -data: {"v":"详细"} - -data: {"v":"天气"} - -data: {"v":"情况"} - -data: {"v":"以及"} - -data: {"v":"生活"} - -data: {"v":"建议"} - -data: {"v":"和"} - -data: {"v":"出行"} - -data: {"v":"提示"} - -data: {"v":"。"} - -data: {"p":"response/fragments/-1/elapsed_secs","o":"SET","v":0.759072346} - -data: {"p":"response/fragments","o":"APPEND","v":[{"id":22,"type":"RESPONSE","content":"广州","references":[],"stage_id":3}]} - -data: {"p":"response/fragments/-1/content","v":"今天"} - -data: {"v":"("} - -data: {"v":"4"} - -data: {"v":"月"} - -data: {"v":"5"} - -data: {"v":"日"} - -data: {"v":","} - -data: {"v":"周日"} - -data: {"v":")"} - -data: {"v":"有"} - -data: {"v":"暴雨"} - -data: {"v":","} - -data: {"v":"并"} - -data: {"v":"伴有"} - -data: {"v":"强"} - -data: {"v":"对流"} - -data: {"v":"天气"} - -data: {"v":","} - -data: {"v":"全天"} - -data: {"v":"体"} - -data: {"v":"感"} - -data: {"v":"湿"} - -data: {"v":"凉"} - -data: {"v":"。\n\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"天气"} - -data: {"v":"与"} - -data: {"v":"气温"} - -data: {"v":"**"} - -data: {"v":":"} - -data: {"v":"阴"} - -data: {"v":"有大"} - -data: {"v":"到"} - -data: {"v":"暴雨"} - -data: {"v":","} - -data: {"v":"21"} - -data: {"v":"℃"} - -data: {"v":" ~"} - -data: {"v":" "} - -data: {"v":"25"} - -data: {"v":"℃"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:0]"},{"p":"references","o":"SET","v":[{"id":18,"type":"TOOL_OPEN"}]}]} - -data: {"v":[{"p":"content","o":"APPEND","v":"[reference:1]"},{"p":"references","v":[{"id":8,"type":"TOOL_SEARCH"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。"} - -data: {"v":"主要"} - -data: {"v":"降水"} - -data: {"v":"集中在"} - -data: {"v":"中午"} - -data: {"v":"至"} - -data: {"v":"傍晚"} - -data: {"v":","} - -data: {"v":"下午"} - -data: {"v":"2"} - -data: {"v":"点左右"} - -data: {"v":"雨"} - -data: {"v":"势"} - -data: {"v":"可能"} - -data: {"v":"最强"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:2]"},{"p":"references","v":[{"id":10,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"风"} - -data: {"v":"况"} - -data: {"v":"**"} - -data: {"v":":"} - -data: {"v":"东风"} - -data: {"v":"转"} - -data: {"v":"东南"} - -data: {"v":"风"} - -data: {"v":","} - -data: {"v":"风力"} - -data: {"v":"普遍"} - -data: {"v":"在"} - -data: {"v":"3"} - -data: {"v":"级"} - -data: {"v":"以下"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:3]"},{"p":"references","v":[{"id":10,"type":"TOOL_OPEN"}]}]} - -data: {"v":[{"p":"content","o":"APPEND","v":"[reference:4]"},{"p":"references","v":[{"id":11,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。"} - -data: {"v":"但"} - -data: {"v":"强"} - -data: {"v":"对流"} - -data: {"v":"天气"} - -data: {"v":"发生时"} - -data: {"v":","} - -data: {"v":"局部"} - -data: {"v":"可能出现"} - -data: {"v":"6"} - -data: {"v":"~"} - -data: {"v":"12"} - -data: {"v":"级的"} - -data: {"v":"短"} - -data: {"v":"时"} - -data: {"v":"大风"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:5]"},{"p":"references","v":[{"id":18,"type":"TOOL_OPEN"}]}]} - -data: {"v":[{"p":"content","o":"APPEND","v":"[reference:6]"},{"p":"references","v":[{"id":3,"type":"TOOL_SEARCH"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"生活"} - -data: {"v":"建议"} - -data: {"v":"**"} - -data: {"v":":"} - -data: {"v":"**"} - -data: {"v":"暴雨"} - -data: {"v":"和"} - -data: {"v":"强"} - -data: {"v":"对流"} - -data: {"v":"天气"} - -data: {"v":","} - -data: {"v":"建议"} - -data: {"v":"减少"} - -data: {"v":"外出"} - -data: {"v":"**"} - -data: {"v":";"} - -data: {"v":"湿度"} - -data: {"v":"大体"} - -data: {"v":"感"} - -data: {"v":"凉"} - -data: {"v":","} - -data: {"v":"适合"} - -data: {"v":"穿"} - -data: {"v":"长"} - -data: {"v":"袖"} - -data: {"v":"衬衫"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:7]"},{"p":"references","v":[{"id":11,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":";"} - -data: {"v":"紫外线"} - -data: {"v":"弱"} - -data: {"v":","} - -data: {"v":"可不"} - -data: {"v":"防晒"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:8]"},{"p":"references","v":[{"id":11,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n\n"} - -data: {"v":"明天"} - -data: {"v":"("} - -data: {"v":"4"} - -data: {"v":"月"} - -data: {"v":"6"} - -data: {"v":"日"} - -data: {"v":")"} - -data: {"v":"降雨"} - -data: {"v":"强度"} - -data: {"v":"会"} - -data: {"v":"减弱"} - -data: {"v":","} - -data: {"v":"转为"} - -data: {"v":"中"} - -data: {"v":"雨"} - -data: {"v":"转"} - -data: {"v":"多云"} - -data: {"v":","} - -data: {"v":"气温"} - -data: {"v":"回升"} - -data: {"v":"至"} - -data: {"v":" "} - -data: {"v":"23"} - -data: {"v":"℃"} - -data: {"v":" ~"} - -data: {"v":" "} - -data: {"v":"30"} - -data: {"v":"℃"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:9]"},{"p":"references","v":[{"id":11,"type":"TOOL_OPEN"}]}]} - -data: {"v":[{"p":"content","o":"APPEND","v":"[reference:10]"},{"p":"references","v":[{"id":18,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。"} - -data: {"v":"后天"} - -data: {"v":"起"} - -data: {"v":"天气"} - -data: {"v":"将"} - -data: {"v":"逐渐"} - -data: {"v":"稳定"} - -data: {"v":","} - -data: {"v":"以"} - -data: {"v":"多云"} - -data: {"v":"和"} - -data: {"v":"雷"} - -data: {"v":"阵"} - -data: {"v":"雨"} - -data: {"v":"为主"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:11]"},{"p":"references","v":[{"id":3,"type":"TOOL_SEARCH"}]}]} - -data: {"v":[{"p":"content","o":"APPEND","v":"[reference:12]"},{"p":"references","v":[{"id":4,"type":"TOOL_SEARCH"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n\n"} - -data: {"v":"天气预报"} - -data: {"v":"信息"} - -data: {"v":"会"} - -data: {"v":"实时"} - -data: {"v":"更新"} - -data: {"v":","} - -data: {"v":"如需"} - -data: {"v":"查询"} - -data: {"v":"最新"} - -data: {"v":"情况"} - -data: {"v":","} - -data: {"v":"可以"} - -data: {"v":"随时"} - -data: {"v":"再来"} - -data: {"v":"问我"} - -data: {"v":"~"} - -data: {"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":211},{"p":"quasi_status","v":"FINISHED"}]} - -data: {"p":"response/status","o":"SET","v":"FINISHED"} - -event: finish -data: {} - -event: update_session -data: {"updated_at":1775318487.947988} - -event: title -data: {"content":"广州天气查询"} - -event: close -data: {"click_behavior":"none","auto_resume":false} - diff --git a/tests/raw_stream_samples/longtext-deepseek-v4-flash-20260429/meta.json b/tests/raw_stream_samples/longtext-deepseek-v4-flash-20260429/meta.json new file mode 100644 index 0000000..153ba99 --- /dev/null +++ b/tests/raw_stream_samples/longtext-deepseek-v4-flash-20260429/meta.json @@ -0,0 +1,37 @@ +{ + "sample_id": "longtext-deepseek-v4-flash-20260429", + "captured_at_utc": "2026-04-29T17:51:14Z", + "source": "admin/dev/raw-samples/capture", + "request": { + "messages": [ + { + "content": "请写一篇1200字中文说明:比较SSE与WebSocket在AI推理流式输出中的可靠性、断线恢复、负载均衡、代理兼容性、成本和可观测性,并给出分层架构建议。", + "role": "user" + } + ], + "model": "deepseek-v4-flash", + "stream": true + }, + "capture": { + "label": "deepseek_upload_file", + "url": "https://chat.deepseek.com/api/v0/file/upload_file", + "status_code": 200, + "response_bytes": 48441, + "rounds": [ + { + "label": "deepseek_upload_file", + "url": "https://chat.deepseek.com/api/v0/file/upload_file", + "status_code": 200, + "response_bytes": 349 + }, + { + "label": "deepseek_completion", + "url": "https://chat.deepseek.com/api/v0/chat/completion", + "status_code": 200, + "response_bytes": 48091 + } + ], + "contains_finished_token": true, + "finished_token_count": 2 + } +} diff --git a/tests/raw_stream_samples/longtext-deepseek-v4-flash-20260429/upstream.stream.sse b/tests/raw_stream_samples/longtext-deepseek-v4-flash-20260429/upstream.stream.sse new file mode 100644 index 0000000..4048f8e --- /dev/null +++ b/tests/raw_stream_samples/longtext-deepseek-v4-flash-20260429/upstream.stream.sse @@ -0,0 +1,4592 @@ +{"code":0,"msg":"","data":{"biz_code":0,"biz_msg":"","biz_data":{"id":"file-b10a2aca-39e9-4a38-be9d-9f22e398cb62","status":"PENDING","file_name":"history.txt","from_share":false,"file_size":732,"model_kind":"NORMAL","token_usage":null,"error_code":null,"inserted_at":1777485015.255,"updated_at":1777485015.255,"is_image":false,"audit_result":null}}} +event: ready +data: {"request_message_id":1,"response_message_id":2,"model_type":"default"} + +event: update_session +data: {"updated_at":1777485019.550396} + +data: {"v":{"response":{"message_id":2,"parent_id":1,"model":"","role":"ASSISTANT","thinking_enabled":true,"ban_edit":false,"ban_regenerate":false,"status":"WIP","incomplete_message":null,"accumulated_token_usage":0,"feedback":null,"inserted_at":1777485019.542662,"search_enabled":false,"fragments":[{"id":2,"type":"THINK","content":"我们","elapsed_secs":null,"references":[],"stage_id":1}],"conversation_mode":"DEFAULT","has_pending_fragment":false,"auto_continue":false}}} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"被"} + +data: {"v":"要求"} + +data: {"v":"写"} + +data: {"v":"一篇"} + +data: {"v":"120"} + +data: {"v":"0"} + +data: {"v":"字"} + +data: {"v":"的中"} + +data: {"v":"文"} + +data: {"v":"说明"} + +data: {"v":","} + +data: {"v":"比较"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"与"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"在"} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"输出"} + +data: {"v":"中的"} + +data: {"v":"多个"} + +data: {"v":"方面"} + +data: {"v":":"} + +data: {"v":"可靠性"} + +data: {"v":"、"} + +data: {"v":"断"} + +data: {"v":"线"} + +data: {"v":"恢复"} + +data: {"v":"、"} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":"、"} + +data: {"v":"代理"} + +data: {"v":"兼容"} + +data: {"v":"性"} + +data: {"v":"、"} + +data: {"v":"成本和"} + +data: {"v":"可"} + +data: {"v":"观测"} + +data: {"v":"性"} + +data: {"v":","} + +data: {"v":"并"} + +data: {"v":"给出"} + +data: {"v":"分层"} + +data: {"v":"架构"} + +data: {"v":"建议"} + +data: {"v":"。"} + +data: {"v":"需要"} + +data: {"v":"非常"} + +data: {"v":"彻底"} + +data: {"v":","} + +data: {"v":"不能"} + +data: {"v":"走"} + +data: {"v":"捷径"} + +data: {"v":"。"} + +data: {"v":"我们将"} + +data: {"v":"逐步"} + +data: {"v":"思考"} + +data: {"v":"并"} + +data: {"v":"写出"} + +data: {"v":"完整"} + +data: {"v":"文章"} + +data: {"v":"。\n\n"} + +data: {"v":"首先"} + +data: {"v":"理解"} + +data: {"v":"背景"} + +data: {"v":":"} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"输出"} + +data: {"v":","} + +data: {"v":"比如"} + +data: {"v":"大"} + +data: {"v":"语言"} + +data: {"v":"模型"} + +data: {"v":"生成"} + +data: {"v":"文本"} + +data: {"v":"时"} + +data: {"v":"逐"} + +data: {"v":"字"} + +data: {"v":"或"} + +data: {"v":"逐"} + +data: {"v":"句"} + +data: {"v":"输出"} + +data: {"v":"。"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":" ("} + +data: {"v":"Server"} + +data: {"v":"-S"} + +data: {"v":"ent"} + +data: {"v":" Events"} + +data: {"v":")"} + +data: {"v":" "} + +data: {"v":"和"} + +data: {"v":" Web"} + +data: {"v":"Socket"} + +data: {"v":" "} + +data: {"v":"是"} + +data: {"v":"两种"} + +data: {"v":"常见"} + +data: {"v":"技术"} + +data: {"v":"。"} + +data: {"v":"我们需要"} + +data: {"v":"比较"} + +data: {"v":"它们在"} + +data: {"v":"上述"} + +data: {"v":"维度"} + +data: {"v":"上的"} + +data: {"v":"优劣"} + +data: {"v":"。\n\n"} + +data: {"v":"1"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"可靠性"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"基于"} + +data: {"v":"HTTP"} + +data: {"v":","} + +data: {"v":"单向"} + +data: {"v":"从"} + +data: {"v":"服务器"} + +data: {"v":"到"} + +data: {"v":"客户端"} + +data: {"v":","} + +data: {"v":"有"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"机制"} + +data: {"v":"("} + +data: {"v":"内置"} + +data: {"v":"Event"} + +data: {"v":"Source"} + +data: {"v":"自动"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"),"} + +data: {"v":"但"} + +data: {"v":"需要"} + +data: {"v":"手动"} + +data: {"v":"处理"} + +data: {"v":"上次"} + +data: {"v":"接收"} + +data: {"v":"位置"} + +data: {"v":"。"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"全"} + +data: {"v":"双"} + +data: {"v":"工"} + +data: {"v":","} + +data: {"v":"但"} + +data: {"v":"需要"} + +data: {"v":"自己"} + +data: {"v":"实现"} + +data: {"v":"心跳"} + +data: {"v":"和"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"逻辑"} + +data: {"v":"。"} + +data: {"v":"可靠性"} + +data: {"v":"上"} + +data: {"v":","} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"简单"} + +data: {"v":"自动"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":","} + +data: {"v":"但"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"可以"} + +data: {"v":"更"} + +data: {"v":"灵活"} + +data: {"v":"控制"} + +data: {"v":"。\n\n"} + +data: {"v":"2"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"断"} + +data: {"v":"线"} + +data: {"v":"恢复"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"可以通过"} + +data: {"v":"Last"} + +data: {"v":"-"} + +data: {"v":"Event"} + +data: {"v":"-ID"} + +data: {"v":"头"} + +data: {"v":"来"} + +data: {"v":"恢复"} + +data: {"v":"丢失"} + +data: {"v":"的消息"} + +data: {"v":"。"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"没有"} + +data: {"v":"内置"} + +data: {"v":"机制"} + +data: {"v":","} + +data: {"v":"需要"} + +data: {"v":"应用"} + +data: {"v":"层"} + +data: {"v":"实现"} + +data: {"v":"序列"} + +data: {"v":"号和"} + +data: {"v":"确认"} + +data: {"v":"。\n\n"} + +data: {"v":"3"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"是"} + +data: {"v":"HTTP"} + +data: {"v":"长"} + +data: {"v":"连接"} + +data: {"v":","} + +data: {"v":"大多数"} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":"器"} + +data: {"v":"支持"} + +data: {"v":"HTTP"} + +data: {"v":","} + +data: {"v":"但"} + +data: {"v":"需要注意"} + +data: {"v":"超"} + +data: {"v":"时"} + +data: {"v":"设置"} + +data: {"v":"。"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"需要"} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":"器"} + +data: {"v":"支持"} + +data: {"v":"协议"} + +data: {"v":"升级"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"NG"} + +data: {"v":"IN"} + +data: {"v":"X"} + +data: {"v":"、"} + +data: {"v":"HA"} + +data: {"v":"Proxy"} + +data: {"v":"支持"} + +data: {"v":"),"} + +data: {"v":"且"} + +data: {"v":"需要"} + +data: {"v":"会话"} + +data: {"v":"保持"} + +data: {"v":"("} + +data: {"v":"st"} + +data: {"v":"icky"} + +data: {"v":" session"} + +data: {"v":"),"} + +data: {"v":"因为"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"是有"} + +data: {"v":"状态的"} + +data: {"v":"。\n\n"} + +data: {"v":"4"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"代理"} + +data: {"v":"兼容"} + +data: {"v":"性"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"兼容"} + +data: {"v":"性好"} + +data: {"v":","} + +data: {"v":"因为"} + +data: {"v":"就是"} + +data: {"v":"HTTP"} + +data: {"v":","} + +data: {"v":"代理"} + +data: {"v":"和"} + +data: {"v":"CD"} + +data: {"v":"N"} + +data: {"v":"都"} + +data: {"v":"支持"} + +data: {"v":"。"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"可能"} + +data: {"v":"被"} + +data: {"v":"一些"} + +data: {"v":"老"} + +data: {"v":"代理"} + +data: {"v":"阻塞"} + +data: {"v":","} + +data: {"v":"需要"} + +data: {"v":"明确"} + +data: {"v":"支持"} + +data: {"v":"。\n\n"} + +data: {"v":"5"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"成本"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"开销"} + +data: {"v":"小"} + +data: {"v":","} + +data: {"v":"HTTP"} + +data: {"v":"头"} + +data: {"v":","} + +data: {"v":"但"} + +data: {"v":"每个"} + +data: {"v":"连接"} + +data: {"v":"占用"} + +data: {"v":"服务器"} + +data: {"v":"资源"} + +data: {"v":"。"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"有"} + +data: {"v":"额外的"} + +data: {"v":"帧"} + +data: {"v":"开销"} + +data: {"v":","} + +data: {"v":"但"} + +data: {"v":"更"} + +data: {"v":"高效"} + +data: {"v":"的双"} + +data: {"v":"向"} + +data: {"v":"传输"} + +data: {"v":"。"} + +data: {"v":"对于"} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":","} + +data: {"v":"主要是"} + +data: {"v":"服务器"} + +data: {"v":"成本"} + +data: {"v":","} + +data: {"v":"两者"} + +data: {"v":"类似"} + +data: {"v":"。"} + +data: {"v":"但"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"可能"} + +data: {"v":"增加"} + +data: {"v":"复杂度"} + +data: {"v":"成本"} + +data: {"v":"。\n\n"} + +data: {"v":"6"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"可"} + +data: {"v":"观测"} + +data: {"v":"性"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"作为"} + +data: {"v":"HTTP"} + +data: {"v":","} + +data: {"v":"可以用"} + +data: {"v":"标准"} + +data: {"v":"监控"} + +data: {"v":"工具"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"日志"} + +data: {"v":"、"} + +data: {"v":"指标"} + +data: {"v":")。"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"自定义"} + +data: {"v":"协议"} + +data: {"v":","} + +data: {"v":"监控"} + +data: {"v":"较"} + +data: {"v":"难"} + +data: {"v":","} + +data: {"v":"需要"} + +data: {"v":"专门"} + +data: {"v":"工具"} + +data: {"v":"。\n\n"} + +data: {"v":"最后"} + +data: {"v":"给出"} + +data: {"v":"分层"} + +data: {"v":"架构"} + +data: {"v":"建议"} + +data: {"v":":"} + +data: {"v":"例如"} + +data: {"v":","} + +data: {"v":"对于"} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"输出"} + +data: {"v":","} + +data: {"v":"建议"} + +data: {"v":"使用"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"作为"} + +data: {"v":"主要"} + +data: {"v":"方式"} + +data: {"v":","} + +data: {"v":"因为"} + +data: {"v":"简单"} + +data: {"v":"、"} + +data: {"v":"兼容"} + +data: {"v":"性好"} + +data: {"v":","} + +data: {"v":"且"} + +data: {"v":"单向"} + +data: {"v":"流"} + +data: {"v":"足够"} + +data: {"v":"。"} + +data: {"v":"但"} + +data: {"v":"如果有"} + +data: {"v":"双向"} + +data: {"v":"交互"} + +data: {"v":"需求"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"用户"} + +data: {"v":"打断"} + +data: {"v":"生成"} + +data: {"v":"),"} + +data: {"v":"则"} + +data: {"v":"考虑"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"。"} + +data: {"v":"也可以"} + +data: {"v":"混合"} + +data: {"v":":"} + +data: {"v":"前端"} + +data: {"v":"用"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":","} + +data: {"v":"后端"} + +data: {"v":"用"} + +data: {"v":"消息"} + +data: {"v":"队列"} + +data: {"v":"等"} + +data: {"v":"。\n\n"} + +data: {"v":"文章"} + +data: {"v":"需要"} + +data: {"v":"120"} + +data: {"v":"0"} + +data: {"v":"字"} + +data: {"v":","} + +data: {"v":"结构"} + +data: {"v":"清晰"} + +data: {"v":","} + +data: {"v":"分"} + +data: {"v":"点"} + +data: {"v":"论述"} + +data: {"v":","} + +data: {"v":"最后"} + +data: {"v":"总结"} + +data: {"v":"和建议"} + +data: {"v":"。\n\n"} + +data: {"v":"我们"} + +data: {"v":"写出"} + +data: {"v":"完整"} + +data: {"v":"文章"} + +data: {"v":"。"} + +data: {"p":"response/fragments/-1/elapsed_secs","o":"SET","v":11.374845301} + +data: {"p":"response/fragments","o":"APPEND","v":[{"id":3,"type":"RESPONSE","content":"##","references":[],"stage_id":1}]} + +data: {"p":"response/fragments/-1/content","v":" "} + +data: {"v":"比较"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"与"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"在"} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"输出"} + +data: {"v":"中的"} + +data: {"v":"关键"} + +data: {"v":"特性"} + +data: {"v":"与"} + +data: {"v":"分层"} + +data: {"v":"架构"} + +data: {"v":"建议"} + +data: {"v":"\n\n"} + +data: {"v":"在"} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"大"} + +data: {"v":"语言"} + +data: {"v":"模型"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"生成"} + +data: {"v":")"} + +data: {"v":"场景"} + +data: {"v":"中"} + +data: {"v":","} + +data: {"v":"服务"} + +data: {"v":"端"} + +data: {"v":"需要"} + +data: {"v":"将"} + +data: {"v":"计算结果"} + +data: {"v":"以"} + +data: {"v":"增量"} + +data: {"v":"方式"} + +data: {"v":"持续"} + +data: {"v":"推"} + +data: {"v":"送给"} + +data: {"v":"客户端"} + +data: {"v":"。"} + +data: {"v":"Server"} + +data: {"v":"-S"} + +data: {"v":"ent"} + +data: {"v":" Events"} + +data: {"v":"("} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":")"} + +data: {"v":"与"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"是"} + +data: {"v":"两种"} + +data: {"v":"主流"} + +data: {"v":"的技术"} + +data: {"v":"方案"} + +data: {"v":"。"} + +data: {"v":"本文"} + +data: {"v":"将从"} + +data: {"v":"可靠性"} + +data: {"v":"、"} + +data: {"v":"断"} + +data: {"v":"线"} + +data: {"v":"恢复"} + +data: {"v":"、"} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":"、"} + +data: {"v":"代理"} + +data: {"v":"兼容"} + +data: {"v":"性"} + +data: {"v":"、"} + +data: {"v":"成本"} + +data: {"v":"及"} + +data: {"v":"可"} + +data: {"v":"观测"} + +data: {"v":"性"} + +data: {"v":"六个"} + +data: {"v":"维度"} + +data: {"v":"进行"} + +data: {"v":"系统"} + +data: {"v":"比较"} + +data: {"v":","} + +data: {"v":"并"} + +data: {"v":"给出"} + +data: {"v":"面向"} + +data: {"v":"实际"} + +data: {"v":"生产"} + +data: {"v":"环境"} + +data: {"v":"的分"} + +data: {"v":"层"} + +data: {"v":"架构"} + +data: {"v":"建议"} + +data: {"v":"。\n\n"} + +data: {"v":"###"} + +data: {"v":" "} + +data: {"v":"一"} + +data: {"v":"、"} + +data: {"v":"可靠性"} + +data: {"v":"\n\n"} + +data: {"v":"-"} + +data: {"v":" **"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"基于"} + +data: {"v":"HTTP"} + +data: {"v":"/"} + +data: {"v":"1"} + +data: {"v":"."} + +data: {"v":"1"} + +data: {"v":"或"} + +data: {"v":"HTTP"} + +data: {"v":"/"} + +data: {"v":"2"} + +data: {"v":","} + +data: {"v":"采用"} + +data: {"v":"标准的"} + +data: {"v":"`"} + +data: {"v":"text"} + +data: {"v":"/"} + +data: {"v":"event"} + +data: {"v":"-stream"} + +data: {"v":"`"} + +data: {"v":"内容"} + +data: {"v":"类型"} + +data: {"v":"。"} + +data: {"v":"连接"} + +data: {"v":"由"} + +data: {"v":"客户端"} + +data: {"v":"发起"} + +data: {"v":","} + +data: {"v":"服务器"} + +data: {"v":"单向"} + +data: {"v":"推送"} + +data: {"v":"。"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"规范"} + +data: {"v":"内置"} + +data: {"v":"了"} + +data: {"v":"自动"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"机制"} + +data: {"v":"——"} + +data: {"v":"当"} + +data: {"v":"连接"} + +data: {"v":"意外"} + +data: {"v":"中断"} + +data: {"v":"时"} + +data: {"v":","} + +data: {"v":"`"} + +data: {"v":"Event"} + +data: {"v":"Source"} + +data: {"v":"`"} + +data: {"v":" API"} + +data: {"v":"会自动"} + +data: {"v":"尝试"} + +data: {"v":"重新"} + +data: {"v":"建立"} + +data: {"v":"连接"} + +data: {"v":","} + +data: {"v":"并"} + +data: {"v":"可通过"} + +data: {"v":"`"} + +data: {"v":"Last"} + +data: {"v":"-"} + +data: {"v":"Event"} + +data: {"v":"-ID"} + +data: {"v":"`"} + +data: {"v":"头"} + +data: {"v":"传递"} + +data: {"v":"最后"} + +data: {"v":"收到的"} + +data: {"v":"消息"} + +data: {"v":"ID"} + +data: {"v":"。"} + +data: {"v":"这种"} + +data: {"v":"机制"} + +data: {"v":"使得"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"在"} + +data: {"v":"纯"} + +data: {"v":"推送"} + +data: {"v":"场景"} + +data: {"v":"下"} + +data: {"v":"具有"} + +data: {"v":"较高的"} + +data: {"v":"基础"} + +data: {"v":"可靠性"} + +data: {"v":"。"} + +data: {"v":"但"} + +data: {"v":"需"} + +data: {"v":"注意"} + +data: {"v":","} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"不支持"} + +data: {"v":"客户端"} + +data: {"v":"主动"} + +data: {"v":"发送"} + +data: {"v":"数据"} + +data: {"v":","} + +data: {"v":"若"} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":"过程"} + +data: {"v":"需要"} + +data: {"v":"客户端"} + +data: {"v":"提供"} + +data: {"v":"额外"} + +data: {"v":"上下文"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"用户"} + +data: {"v":"打断"} + +data: {"v":"生成"} + +data: {"v":"),"} + +data: {"v":"则"} + +data: {"v":"必须"} + +data: {"v":"通过"} + +data: {"v":"另一个"} + +data: {"v":"HTTP"} + +data: {"v":"请求"} + +data: {"v":"完成"} + +data: {"v":","} + +data: {"v":"增加了"} + +data: {"v":"交互"} + +data: {"v":"复杂性"} + +data: {"v":"。\n\n"} + +data: {"v":"-"} + +data: {"v":" **"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"全"} + +data: {"v":"双"} + +data: {"v":"工"} + +data: {"v":"、"} + +data: {"v":"持久"} + +data: {"v":"化的"} + +data: {"v":"二进制"} + +data: {"v":"协议"} + +data: {"v":","} + +data: {"v":"通过"} + +data: {"v":"HTTP"} + +data: {"v":" Upgrade"} + +data: {"v":"握手"} + +data: {"v":"建立"} + +data: {"v":"。"} + +data: {"v":"其"} + +data: {"v":"可靠性"} + +data: {"v":"完全"} + +data: {"v":"依赖"} + +data: {"v":"应用"} + +data: {"v":"层"} + +data: {"v":"实现"} + +data: {"v":":"} + +data: {"v":"心跳"} + +data: {"v":"机制"} + +data: {"v":"("} + +data: {"v":"P"} + +data: {"v":"ing"} + +data: {"v":"/P"} + +data: {"v":"ong"} + +data: {"v":")"} + +data: {"v":"需"} + +data: {"v":"自行"} + +data: {"v":"设计"} + +data: {"v":","} + +data: {"v":"断"} + +data: {"v":"线"} + +data: {"v":"检测"} + +data: {"v":"和"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"逻辑"} + +data: {"v":"("} + +data: {"v":"指数"} + +data: {"v":"退"} + +data: {"v":"避"} + +data: {"v":"、"} + +data: {"v":"随机"} + +data: {"v":"抖动"} + +data: {"v":")"} + +data: {"v":"也"} + +data: {"v":"需"} + +data: {"v":"编码"} + +data: {"v":"完成"} + +data: {"v":"。"} + +data: {"v":"好处"} + +data: {"v":"是"} + +data: {"v":"灵活"} + +data: {"v":"度高"} + +data: {"v":","} + +data: {"v":"可"} + +data: {"v":"针对"} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":"流"} + +data: {"v":"定制"} + +data: {"v":"确认"} + +data: {"v":"重"} + +data: {"v":"传"} + +data: {"v":"、"} + +data: {"v":"流量"} + +data: {"v":"控制"} + +data: {"v":"等"} + +data: {"v":"策略"} + +data: {"v":"。"} + +data: {"v":"坏"} + +data: {"v":"处"} + +data: {"v":"是"} + +data: {"v":"原生"} + +data: {"v":"缺少"} + +data: {"v":"标准"} + +data: {"v":"化的"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"与"} + +data: {"v":"恢复"} + +data: {"v":"语义"} + +data: {"v":","} + +data: {"v":"实现"} + +data: {"v":"不当"} + +data: {"v":"易"} + +data: {"v":"导致"} + +data: {"v":"数据"} + +data: {"v":"丢失"} + +data: {"v":"或"} + +data: {"v":"重复"} + +data: {"v":"。\n\n"} + +data: {"v":"**"} + +data: {"v":"结论"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"对于"} + +data: {"v":"“"} + +data: {"v":"仅"} + +data: {"v":"服务器"} + +data: {"v":"推送"} + +data: {"v":"推理"} + +data: {"v":"结果"} + +data: {"v":"”"} + +data: {"v":"的"} + +data: {"v":"典型"} + +data: {"v":"场景"} + +data: {"v":","} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"的"} + +data: {"v":"默认"} + +data: {"v":"可靠"} + +data: {"v":"行为"} + +data: {"v":"更"} + +data: {"v":"简单"} + +data: {"v":"稳健"} + +data: {"v":";"} + +data: {"v":"若"} + +data: {"v":"需"} + +data: {"v":"复杂"} + +data: {"v":"双向"} + +data: {"v":"控制"} + +data: {"v":","} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"可通过"} + +data: {"v":"精心"} + +data: {"v":"设计"} + +data: {"v":"达到"} + +data: {"v":"更高"} + +data: {"v":"可靠性"} + +data: {"v":","} + +data: {"v":"但"} + +data: {"v":"开发"} + +data: {"v":"成本"} + +data: {"v":"显著"} + +data: {"v":"上升"} + +data: {"v":"。\n\n"} + +data: {"v":"###"} + +data: {"v":" "} + +data: {"v":"二"} + +data: {"v":"、"} + +data: {"v":"断"} + +data: {"v":"线"} + +data: {"v":"恢复"} + +data: {"v":"\n\n"} + +data: {"v":"-"} + +data: {"v":" **"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"利用"} + +data: {"v":"`"} + +data: {"v":"Last"} + +data: {"v":"-"} + +data: {"v":"Event"} + +data: {"v":"-ID"} + +data: {"v":"`"} + +data: {"v":"实现"} + +data: {"v":"天然"} + +data: {"v":"断"} + +data: {"v":"点"} + +data: {"v":"续"} + +data: {"v":"传"} + +data: {"v":"。"} + +data: {"v":"服务器"} + +data: {"v":"为"} + +data: {"v":"每个"} + +data: {"v":"推送"} + +data: {"v":"事件"} + +data: {"v":"分配"} + +data: {"v":"单调"} + +data: {"v":"递增"} + +data: {"v":"ID"} + +data: {"v":","} + +data: {"v":"客户端"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"时"} + +data: {"v":"自动"} + +data: {"v":"携带"} + +data: {"v":"最后"} + +data: {"v":"收到"} + +data: {"v":"ID"} + +data: {"v":","} + +data: {"v":"服务"} + +data: {"v":"端"} + +data: {"v":"据此"} + +data: {"v":"回"} + +data: {"v":"补"} + +data: {"v":"丢失"} + +data: {"v":"消息"} + +data: {"v":"。"} + +data: {"v":"这使得"} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":"中间"} + +data: {"v":"断开"} + +data: {"v":"后"} + +data: {"v":"能够"} + +data: {"v":"无"} + +data: {"v":"遗漏"} + +data: {"v":"恢复"} + +data: {"v":"输出"} + +data: {"v":","} + +data: {"v":"对"} + +data: {"v":"用户体验"} + +data: {"v":"至关重要"} + +data: {"v":"。\n\n"} + +data: {"v":"-"} + +data: {"v":" **"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"无"} + +data: {"v":"任何"} + +data: {"v":"内"} + +data: {"v":"建"} + +data: {"v":"恢复"} + +data: {"v":"机制"} + +data: {"v":"。"} + +data: {"v":"需要在"} + +data: {"v":"应用"} + +data: {"v":"协议"} + +data: {"v":"中"} + +data: {"v":"自行"} + +data: {"v":"设计"} + +data: {"v":"序列"} + +data: {"v":"号"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"每个"} + +data: {"v":"推理"} + +data: {"v":"token"} + +data: {"v":"带"} + +data: {"v":"序号"} + +data: {"v":")"} + +data: {"v":"和"} + +data: {"v":"确认"} + +data: {"v":"应答"} + +data: {"v":"("} + +data: {"v":"ACK"} + +data: {"v":"),"} + +data: {"v":"客户端"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"后"} + +data: {"v":"发送"} + +data: {"v":"已"} + +data: {"v":"确认"} + +data: {"v":"序号"} + +data: {"v":","} + +data: {"v":"服务"} + +data: {"v":"端"} + +data: {"v":"重"} + +data: {"v":"放"} + +data: {"v":"丢失"} + +data: {"v":"部分"} + +data: {"v":"。"} + +data: {"v":"这"} + +data: {"v":"增加了"} + +data: {"v":"协议"} + +data: {"v":"设计"} + +data: {"v":"负担"} + +data: {"v":","} + +data: {"v":"且"} + +data: {"v":"需"} + +data: {"v":"处理"} + +data: {"v":"消息"} + +data: {"v":"边界"} + +data: {"v":"与"} + +data: {"v":"缓存"} + +data: {"v":"。\n\n"} + +data: {"v":"**"} + +data: {"v":"结论"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"在"} + +data: {"v":"断"} + +data: {"v":"线"} + +data: {"v":"恢复"} + +data: {"v":"上"} + +data: {"v":"胜"} + +data: {"v":"出"} + +data: {"v":"——"} + +data: {"v":"开"} + +data: {"v":"箱"} + +data: {"v":"即"} + +data: {"v":"用"} + +data: {"v":","} + +data: {"v":"适合"} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":"这类"} + +data: {"v":"需要"} + +data: {"v":"连续"} + +data: {"v":"输出"} + +data: {"v":"且"} + +data: {"v":"不允许"} + +data: {"v":"跳"} + +data: {"v":"帧"} + +data: {"v":"的场景"} + +data: {"v":"。\n\n"} + +data: {"v":"###"} + +data: {"v":" "} + +data: {"v":"三"} + +data: {"v":"、"} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":"\n\n"} + +data: {"v":"-"} + +data: {"v":" **"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"本质"} + +data: {"v":"是"} + +data: {"v":"长"} + +data: {"v":"连接"} + +data: {"v":"HTTP"} + +data: {"v":"请求"} + +data: {"v":"。"} + +data: {"v":"大多数"} + +data: {"v":"七"} + +data: {"v":"层"} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":"器"} + +data: {"v":"("} + +data: {"v":"NG"} + +data: {"v":"IN"} + +data: {"v":"X"} + +data: {"v":"、"} + +data: {"v":"HA"} + +data: {"v":"Proxy"} + +data: {"v":"、"} + +data: {"v":"AWS"} + +data: {"v":" AL"} + +data: {"v":"B"} + +data: {"v":")"} + +data: {"v":"对"} + +data: {"v":"HTTP"} + +data: {"v":"长"} + +data: {"v":"连接"} + +data: {"v":"支持"} + +data: {"v":"成熟"} + +data: {"v":","} + +data: {"v":"只需"} + +data: {"v":"配置"} + +data: {"v":"较"} + +data: {"v":"长的"} + +data: {"v":"`"} + +data: {"v":"keep"} + +data: {"v":"al"} + +data: {"v":"ive"} + +data: {"v":"_time"} + +data: {"v":"out"} + +data: {"v":"`"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"300"} + +data: {"v":"秒"} + +data: {"v":")"} + +data: {"v":"和"} + +data: {"v":"连接"} + +data: {"v":"数"} + +data: {"v":"限制"} + +data: {"v":"。"} + +data: {"v":"由于"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"连接"} + +data: {"v":"可"} + +data: {"v":"识别"} + +data: {"v":"为"} + +data: {"v":"普通"} + +data: {"v":"HTTP"} + +data: {"v":"流"} + +data: {"v":","} + +data: {"v":"不"} + +data: {"v":"要求"} + +data: {"v":"会话"} + +data: {"v":"保持"} + +data: {"v":"("} + +data: {"v":"session"} + +data: {"v":" persistence"} + +data: {"v":"),"} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":"器"} + +data: {"v":"可"} + +data: {"v":"自由"} + +data: {"v":"分配"} + +data: {"v":"请求"} + +data: {"v":","} + +data: {"v":"扩展"} + +data: {"v":"性好"} + +data: {"v":"。\n\n"} + +data: {"v":"-"} + +data: {"v":" **"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"要求"} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":"器"} + +data: {"v":"显"} + +data: {"v":"式"} + +data: {"v":"支持"} + +data: {"v":"协议"} + +data: {"v":"升级"} + +data: {"v":"和"} + +data: {"v":"隧道"} + +data: {"v":"传输"} + +data: {"v":"。"} + +data: {"v":"通常"} + +data: {"v":"需要"} + +data: {"v":"开启"} + +data: {"v":"“"} + +data: {"v":"st"} + +data: {"v":"icky"} + +data: {"v":" session"} + +data: {"v":"”("} + +data: {"v":"基于"} + +data: {"v":"IP"} + +data: {"v":"哈希"} + +data: {"v":"或"} + +data: {"v":"Cookie"} + +data: {"v":"),"} + +data: {"v":"因为"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"连接"} + +data: {"v":"建立"} + +data: {"v":"后"} + +data: {"v":"状态"} + +data: {"v":"固定"} + +data: {"v":"于"} + +data: {"v":"某"} + +data: {"v":"台"} + +data: {"v":"后端"} + +data: {"v":"服务器"} + +data: {"v":","} + +data: {"v":"一旦"} + +data: {"v":"断"} + +data: {"v":"线"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"可能"} + +data: {"v":"被"} + +data: {"v":"路由"} + +data: {"v":"到"} + +data: {"v":"不同"} + +data: {"v":"节点"} + +data: {"v":","} + +data: {"v":"导致"} + +data: {"v":"会话"} + +data: {"v":"丢失"} + +data: {"v":"。"} + +data: {"v":"这对"} + +data: {"v":"水平"} + +data: {"v":"伸缩"} + +data: {"v":"和"} + +data: {"v":"故障"} + +data: {"v":"转移"} + +data: {"v":"带来"} + +data: {"v":"挑战"} + +data: {"v":","} + +data: {"v":"需"} + +data: {"v":"配合"} + +data: {"v":"共享"} + +data: {"v":"存储"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"Redis"} + +data: {"v":")"} + +data: {"v":"存储"} + +data: {"v":"会话"} + +data: {"v":"状态"} + +data: {"v":","} + +data: {"v":"或"} + +data: {"v":"采用"} + +data: {"v":"一致性"} + +data: {"v":"哈希"} + +data: {"v":"路由"} + +data: {"v":"。\n\n"} + +data: {"v":"**"} + +data: {"v":"结论"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"在"} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":"层面"} + +data: {"v":"更"} + +data: {"v":"简单"} + +data: {"v":"、"} + +data: {"v":"更"} + +data: {"v":"无"} + +data: {"v":"状态"} + +data: {"v":","} + +data: {"v":"更适合"} + +data: {"v":"云"} + +data: {"v":"原生"} + +data: {"v":"环境中"} + +data: {"v":"频繁"} + +data: {"v":"扩"} + +data: {"v":"缩"} + +data: {"v":"容"} + +data: {"v":"的"} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":"服务"} + +data: {"v":"。\n\n"} + +data: {"v":"###"} + +data: {"v":" "} + +data: {"v":"四"} + +data: {"v":"、"} + +data: {"v":"代理"} + +data: {"v":"兼容"} + +data: {"v":"性"} + +data: {"v":"\n\n"} + +data: {"v":"-"} + +data: {"v":" **"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"几乎"} + +data: {"v":"100"} + +data: {"v":"%"} + +data: {"v":"兼容"} + +data: {"v":"所有"} + +data: {"v":"HTTP"} + +data: {"v":"代理"} + +data: {"v":"、"} + +data: {"v":"CD"} + +data: {"v":"N"} + +data: {"v":"、"} + +data: {"v":"网关"} + +data: {"v":"("} + +data: {"v":"包括"} + +data: {"v":"企业"} + +data: {"v":"防火墙"} + +data: {"v":")。"} + +data: {"v":"因"} + +data: {"v":"它"} + +data: {"v":"只是"} + +data: {"v":"分"} + +data: {"v":"块"} + +data: {"v":"传输"} + +data: {"v":"的"} + +data: {"v":"普通"} + +data: {"v":"HTTP"} + +data: {"v":"响应"} + +data: {"v":","} + +data: {"v":"代理"} + +data: {"v":"不会"} + +data: {"v":"特殊"} + +data: {"v":"处理"} + +data: {"v":","} + +data: {"v":"只需"} + +data: {"v":"注意"} + +data: {"v":"关闭"} + +data: {"v":"代理"} + +data: {"v":"的"} + +data: {"v":"响应"} + +data: {"v":"缓冲"} + +data: {"v":"("} + +data: {"v":"`"} + +data: {"v":"X"} + +data: {"v":"-A"} + +data: {"v":"cc"} + +data: {"v":"el"} + +data: {"v":"-B"} + +data: {"v":"uff"} + +data: {"v":"ering"} + +data: {"v":":"} + +data: {"v":" no"} + +data: {"v":"`"} + +data: {"v":"或"} + +data: {"v":"`"} + +data: {"v":"proxy"} + +data: {"v":"_b"} + +data: {"v":"uff"} + +data: {"v":"ering"} + +data: {"v":" off"} + +data: {"v":"`"} + +data: {"v":")"} + +data: {"v":"即可"} + +data: {"v":"实现"} + +data: {"v":"实时"} + +data: {"v":"流传"} + +data: {"v":"。\n\n"} + +data: {"v":"-"} + +data: {"v":" **"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"需要"} + +data: {"v":"代理"} + +data: {"v":"完全"} + +data: {"v":"理解"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"握手"} + +data: {"v":"和"} + +data: {"v":"帧"} + +data: {"v":"格式"} + +data: {"v":"。"} + +data: {"v":"部分"} + +data: {"v":"老旧"} + +data: {"v":"代理"} + +data: {"v":"、"} + +data: {"v":"反向"} + +data: {"v":"代理"} + +data: {"v":"或"} + +data: {"v":"CD"} + +data: {"v":"N"} + +data: {"v":"可能"} + +data: {"v":"不支持"} + +data: {"v":"或"} + +data: {"v":"错误"} + +data: {"v":"拦截"} + +data: {"v":"Up"} + +data: {"v":"grade"} + +data: {"v":"请求"} + +data: {"v":"。"} + +data: {"v":"即使"} + +data: {"v":"支持"} + +data: {"v":","} + +data: {"v":"也"} + +data: {"v":"往往"} + +data: {"v":"需要"} + +data: {"v":"单独"} + +data: {"v":"配置"} + +data: {"v":"协议"} + +data: {"v":"升级"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"NG"} + +data: {"v":"IN"} + +data: {"v":"X"} + +data: {"v":"的"} + +data: {"v":"`"} + +data: {"v":"Up"} + +data: {"v":"grade"} + +data: {"v":":"} + +data: {"v":" webs"} + +data: {"v":"ocket"} + +data: {"v":"`"} + +data: {"v":"头"} + +data: {"v":")。"} + +data: {"v":"此外"} + +data: {"v":","} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"流量"} + +data: {"v":"经过"} + +data: {"v":"代理"} + +data: {"v":"时"} + +data: {"v":","} + +data: {"v":"某些"} + +data: {"v":"超"} + +data: {"v":"时"} + +data: {"v":"设置"} + +data: {"v":"不"} + +data: {"v":"匹配"} + +data: {"v":"会导致"} + +data: {"v":"静"} + +data: {"v":"默"} + +data: {"v":"断"} + +data: {"v":"连"} + +data: {"v":"。\n\n"} + +data: {"v":"**"} + +data: {"v":"结论"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"的"} + +data: {"v":"代理"} + +data: {"v":"兼容"} + +data: {"v":"性"} + +data: {"v":"显著"} + +data: {"v":"优于"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":","} + +data: {"v":"尤其在"} + +data: {"v":"混合"} + +data: {"v":"网络"} + +data: {"v":"环境"} + +data: {"v":"("} + +data: {"v":"移动"} + +data: {"v":"网络"} + +data: {"v":"、"} + +data: {"v":"公司"} + +data: {"v":"代理"} + +data: {"v":")"} + +data: {"v":"中"} + +data: {"v":"更为"} + +data: {"v":"可靠"} + +data: {"v":"。\n\n"} + +data: {"v":"###"} + +data: {"v":" "} + +data: {"v":"五"} + +data: {"v":"、"} + +data: {"v":"成本"} + +data: {"v":"\n\n"} + +data: {"v":"-"} + +data: {"v":" **"} + +data: {"v":"连接"} + +data: {"v":"开销"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"的"} + +data: {"v":"HTTP"} + +data: {"v":"头部"} + +data: {"v":"每"} + +data: {"v":"请求"} + +data: {"v":"约"} + +data: {"v":"200"} + +data: {"v":"~"} + +data: {"v":"500"} + +data: {"v":"字节"} + +data: {"v":","} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"握手"} + +data: {"v":"后"} + +data: {"v":"帧"} + +data: {"v":"头"} + +data: {"v":"仅"} + +data: {"v":"2"} + +data: {"v":"~"} + +data: {"v":"14"} + +data: {"v":"字节"} + +data: {"v":","} + +data: {"v":"且"} + +data: {"v":"无"} + +data: {"v":"每次"} + +data: {"v":"推送"} + +data: {"v":"的"} + +data: {"v":"重复"} + +data: {"v":"HTTP"} + +data: {"v":"头"} + +data: {"v":"。"} + +data: {"v":"对于"} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":"中"} + +data: {"v":"高频"} + +data: {"v":"逐"} + +data: {"v":"token"} + +data: {"v":"输出"} + +data: {"v":"("} + +data: {"v":"数百"} + +data: {"v":"至"} + +data: {"v":"数千"} + +data: {"v":"次"} + +data: {"v":"推送"} + +data: {"v":"),"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"的"} + +data: {"v":"带宽"} + +data: {"v":"效率"} + +data: {"v":"略"} + +data: {"v":"高"} + +data: {"v":"。\n"} + +data: {"v":"-"} + +data: {"v":" **"} + +data: {"v":"服务器"} + +data: {"v":"资源"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"每个"} + +data: {"v":"连接"} + +data: {"v":"占用"} + +data: {"v":"一个"} + +data: {"v":"HTTP"} + +data: {"v":"/"} + +data: {"v":"1"} + +data: {"v":"."} + +data: {"v":"1"} + +data: {"v":"线程"} + +data: {"v":"或"} + +data: {"v":"事件"} + +data: {"v":"循环"} + +data: {"v":"中的"} + +data: {"v":"文件"} + +data: {"v":"描述"} + +data: {"v":"符"} + +data: {"v":";"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"类似"} + +data: {"v":"但"} + +data: {"v":"无"} + +data: {"v":"HTTP"} + +data: {"v":"解析"} + +data: {"v":"负担"} + +data: {"v":"。"} + +data: {"v":"现代"} + +data: {"v":"异步"} + +data: {"v":"框架"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"Net"} + +data: {"v":"ty"} + +data: {"v":"、"} + +data: {"v":"Node"} + +data: {"v":".js"} + +data: {"v":")"} + +data: {"v":"下"} + +data: {"v":"两者"} + +data: {"v":"资源"} + +data: {"v":"消耗"} + +data: {"v":"相近"} + +data: {"v":"。\n"} + +data: {"v":"-"} + +data: {"v":" **"} + +data: {"v":"开发"} + +data: {"v":"与"} + +data: {"v":"运维"} + +data: {"v":"成本"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"实现"} + +data: {"v":"简单"} + +data: {"v":","} + +data: {"v":"无需"} + +data: {"v":"自定义"} + +data: {"v":"心跳"} + +data: {"v":"、"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"、"} + +data: {"v":"序列"} + +data: {"v":"号"} + +data: {"v":","} + +data: {"v":"大幅"} + +data: {"v":"降低"} + +data: {"v":"编码"} + +data: {"v":"与"} + +data: {"v":"调试"} + +data: {"v":"成本"} + +data: {"v":"。"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"需"} + +data: {"v":"额外"} + +data: {"v":"处理"} + +data: {"v":"断"} + +data: {"v":"线"} + +data: {"v":"恢复"} + +data: {"v":"、"} + +data: {"v":"会话"} + +data: {"v":"迁移"} + +data: {"v":"、"} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":"粘"} + +data: {"v":"性"} + +data: {"v":"等"} + +data: {"v":","} + +data: {"v":"总体"} + +data: {"v":"拥有"} + +data: {"v":"成本"} + +data: {"v":"("} + +data: {"v":"T"} + +data: {"v":"CO"} + +data: {"v":")"} + +data: {"v":"更高"} + +data: {"v":"。\n\n"} + +data: {"v":"**"} + +data: {"v":"结论"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"对于"} + +data: {"v":"大多数"} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":"业务"} + +data: {"v":","} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"的开发"} + +data: {"v":"运维"} + +data: {"v":"成本"} + +data: {"v":"远"} + +data: {"v":"低于"} + +data: {"v":"其"} + +data: {"v":"微小"} + +data: {"v":"带宽"} + +data: {"v":"浪费"} + +data: {"v":","} + +data: {"v":"因此"} + +data: {"v":"总体"} + +data: {"v":"成本"} + +data: {"v":"更"} + +data: {"v":"优"} + +data: {"v":"。\n\n"} + +data: {"v":"###"} + +data: {"v":" "} + +data: {"v":"六"} + +data: {"v":"、"} + +data: {"v":"可"} + +data: {"v":"观测"} + +data: {"v":"性"} + +data: {"v":"\n\n"} + +data: {"v":"-"} + +data: {"v":" **"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"基于"} + +data: {"v":"HTTP"} + +data: {"v":"标准"} + +data: {"v":","} + +data: {"v":"可直接"} + +data: {"v":"利用"} + +data: {"v":"现有"} + +data: {"v":"监控"} + +data: {"v":"体系"} + +data: {"v":":"} + +data: {"v":"通过"} + +data: {"v":"网关"} + +data: {"v":"日志"} + +data: {"v":"获取"} + +data: {"v":"请求"} + +data: {"v":"耗时"} + +data: {"v":"、"} + +data: {"v":"状态"} + +data: {"v":"码"} + +data: {"v":"、"} + +data: {"v":"响应"} + +data: {"v":"大小"} + +data: {"v":";"} + +data: {"v":"通过"} + +data: {"v":"追踪"} + +data: {"v":"ID"} + +data: {"v":"实现"} + +data: {"v":"分布式"} + +data: {"v":"链路"} + +data: {"v":"追踪"} + +data: {"v":"("} + +data: {"v":"Open"} + +data: {"v":"Tele"} + +data: {"v":"metry"} + +data: {"v":"自动"} + +data: {"v":"支持"} + +data: {"v":");"} + +data: {"v":"错误"} + +data: {"v":"率"} + +data: {"v":"、"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"次数"} + +data: {"v":"等"} + +data: {"v":"指标"} + +data: {"v":"均可"} + +data: {"v":"从"} + +data: {"v":"标准"} + +data: {"v":"指标"} + +data: {"v":"采集"} + +data: {"v":"器"} + +data: {"v":"获得"} + +data: {"v":"。"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"的"} + +data: {"v":"每个"} + +data: {"v":"事件"} + +data: {"v":"天然"} + +data: {"v":"是"} + +data: {"v":"结构化"} + +data: {"v":"文本"} + +data: {"v":","} + +data: {"v":"便于"} + +data: {"v":"记录"} + +data: {"v":"和分析"} + +data: {"v":"。\n\n"} + +data: {"v":"-"} + +data: {"v":" **"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"自定义"} + +data: {"v":"二进制"} + +data: {"v":"协议"} + +data: {"v":"使得"} + +data: {"v":"通用"} + +data: {"v":"HTTP"} + +data: {"v":"监控"} + +data: {"v":"工具"} + +data: {"v":"失效"} + +data: {"v":"。"} + +data: {"v":"需要"} + +data: {"v":"专门"} + +data: {"v":"埋"} + +data: {"v":"点"} + +data: {"v":":"} + +data: {"v":"记录"} + +data: {"v":"消息"} + +data: {"v":"发送"} + +data: {"v":"速率"} + +data: {"v":"、"} + +data: {"v":"延迟"} + +data: {"v":"、"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"计数"} + +data: {"v":"、"} + +data: {"v":"异常"} + +data: {"v":"关闭"} + +data: {"v":"码"} + +data: {"v":"。"} + +data: {"v":"链路"} + +data: {"v":"追踪"} + +data: {"v":"需"} + +data: {"v":"在"} + +data: {"v":"应用"} + +data: {"v":"层"} + +data: {"v":"手动"} + +data: {"v":"注入"} + +data: {"v":"上下文"} + +data: {"v":","} + +data: {"v":"无法"} + +data: {"v":"依赖"} + +data: {"v":"代理"} + +data: {"v":"自动"} + +data: {"v":"生成"} + +data: {"v":"。"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"连接"} + +data: {"v":"的生命"} + +data: {"v":"周期"} + +data: {"v":"事件"} + +data: {"v":"("} + +data: {"v":"打开"} + +data: {"v":"、"} + +data: {"v":"关闭"} + +data: {"v":"、"} + +data: {"v":"ping"} + +data: {"v":"/p"} + +data: {"v":"ong"} + +data: {"v":")"} + +data: {"v":"也"} + +data: {"v":"需"} + +data: {"v":"额外"} + +data: {"v":"暴露"} + +data: {"v":"为"} + +data: {"v":"指标"} + +data: {"v":"。\n\n"} + +data: {"v":"**"} + +data: {"v":"结论"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"的可"} + +data: {"v":"观测"} + +data: {"v":"性"} + +data: {"v":"实现"} + +data: {"v":"成本"} + +data: {"v":"极"} + +data: {"v":"低"} + +data: {"v":"且"} + +data: {"v":"标准化"} + +data: {"v":","} + +data: {"v":"而"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"需"} + +data: {"v":"大量"} + +data: {"v":"定制"} + +data: {"v":"代码"} + +data: {"v":"才能"} + +data: {"v":"达到"} + +data: {"v":"同等"} + +data: {"v":"可见"} + +data: {"v":"性"} + +data: {"v":"。\n\n"} + +data: {"v":"###"} + +data: {"v":" "} + +data: {"v":"分层"} + +data: {"v":"架构"} + +data: {"v":"建议"} + +data: {"v":"\n\n"} + +data: {"v":"基于"} + +data: {"v":"以上"} + +data: {"v":"分析"} + +data: {"v":","} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"输出的"} + +data: {"v":"最佳"} + +data: {"v":"实践"} + +data: {"v":"建议"} + +data: {"v":"采用"} + +data: {"v":"**"} + +data: {"v":"分层"} + +data: {"v":"混合"} + +data: {"v":"架构"} + +data: {"v":"**"} + +data: {"v":":\n\n"} + +data: {"v":"1"} + +data: {"v":"."} + +data: {"v":" **"} + +data: {"v":"接入"} + +data: {"v":"层"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"使用"} + +data: {"v":"支持"} + +data: {"v":"HTTP"} + +data: {"v":"/"} + +data: {"v":"2"} + +data: {"v":"或"} + +data: {"v":"HTTP"} + +data: {"v":"/"} + +data: {"v":"1"} + +data: {"v":"."} + +data: {"v":"1"} + +data: {"v":"的"} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":"器"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"AL"} + +data: {"v":"B"} + +data: {"v":"、"} + +data: {"v":"NG"} + +data: {"v":"IN"} + +data: {"v":"X"} + +data: {"v":"),"} + +data: {"v":"开启"} + +data: {"v":"响应"} + +data: {"v":"缓冲"} + +data: {"v":"关闭"} + +data: {"v":"("} + +data: {"v":"`"} + +data: {"v":"proxy"} + +data: {"v":"_b"} + +data: {"v":"uff"} + +data: {"v":"ering"} + +data: {"v":" off"} + +data: {"v":"`"} + +data: {"v":")"} + +data: {"v":"和"} + +data: {"v":"长"} + +data: {"v":"连接"} + +data: {"v":"超"} + +data: {"v":"时"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"600"} + +data: {"v":"秒"} + +data: {"v":")。\n"} + +data: {"v":"2"} + +data: {"v":"."} + +data: {"v":" **"} + +data: {"v":"协议"} + +data: {"v":"层"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"**"} + +data: {"v":"优先"} + +data: {"v":"选择"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"**"} + +data: {"v":"作为"} + +data: {"v":"客户端"} + +data: {"v":"与服务"} + +data: {"v":"端"} + +data: {"v":"之间的"} + +data: {"v":"主要"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"协议"} + +data: {"v":"**"} + +data: {"v":","} + +data: {"v":"理由"} + +data: {"v":"如下"} + +data: {"v":":\n"} + +data: {"v":" "} + +data: {"v":" -"} + +data: {"v":" AI"} + +data: {"v":"推理"} + +data: {"v":"是"} + +data: {"v":"典型的"} + +data: {"v":"服务器"} + +data: {"v":"向"} + +data: {"v":"客户端"} + +data: {"v":"推送"} + +data: {"v":"单向"} + +data: {"v":"流"} + +data: {"v":","} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"天然"} + +data: {"v":"匹配"} + +data: {"v":"。\n"} + +data: {"v":" "} + +data: {"v":" -"} + +data: {"v":" "} + +data: {"v":"断"} + +data: {"v":"线"} + +data: {"v":"恢复"} + +data: {"v":"内置"} + +data: {"v":","} + +data: {"v":"极大"} + +data: {"v":"提升"} + +data: {"v":"用户体验"} + +data: {"v":","} + +data: {"v":"尤其"} + +data: {"v":"适合"} + +data: {"v":"生"} + +data: {"v":"成长"} + +data: {"v":"度"} + +data: {"v":"可达"} + +data: {"v":"数千"} + +data: {"v":"token"} + +data: {"v":"的"} + +data: {"v":"推理"} + +data: {"v":"任务"} + +data: {"v":"。\n"} + +data: {"v":" "} + +data: {"v":" -"} + +data: {"v":" "} + +data: {"v":"无需"} + +data: {"v":"自定义"} + +data: {"v":"粘"} + +data: {"v":"性"} + +data: {"v":"会话"} + +data: {"v":","} + +data: {"v":"水平"} + +data: {"v":"扩展"} + +data: {"v":"简单"} + +data: {"v":"。\n"} + +data: {"v":" "} + +data: {"v":" -"} + +data: {"v":" "} + +data: {"v":"运维"} + +data: {"v":"与"} + +data: {"v":"监控"} + +data: {"v":"可直接"} + +data: {"v":"复用"} + +data: {"v":"现有"} + +data: {"v":"HTTP"} + +data: {"v":"生态"} + +data: {"v":"。\n"} + +data: {"v":"3"} + +data: {"v":"."} + +data: {"v":" **"} + +data: {"v":"双向"} + +data: {"v":"交互"} + +data: {"v":"补充"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"如果"} + +data: {"v":"业务"} + +data: {"v":"的确"} + +data: {"v":"需要"} + +data: {"v":"用户"} + +data: {"v":"打断"} + +data: {"v":"生成"} + +data: {"v":"或"} + +data: {"v":"实时"} + +data: {"v":"调整"} + +data: {"v":"推理"} + +data: {"v":"参数"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"温度"} + +data: {"v":"、"} + +data: {"v":"top"} + +data: {"v":"_p"} + +data: {"v":"),"} + +data: {"v":"可通过"} + +data: {"v":"以下"} + +data: {"v":"两种"} + +data: {"v":"方式"} + +data: {"v":"实现"} + +data: {"v":":\n"} + +data: {"v":" "} + +data: {"v":" -"} + +data: {"v":" **"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":" +"} + +data: {"v":" "} + +data: {"v":"独立"} + +data: {"v":"HTTP"} + +data: {"v":" POST"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"客户端"} + +data: {"v":"另"} + +data: {"v":"发起"} + +data: {"v":"一个"} + +data: {"v":"HTTP"} + +data: {"v":"请求"} + +data: {"v":"发送"} + +data: {"v":"控制"} + +data: {"v":"指令"} + +data: {"v":","} + +data: {"v":"服务"} + +data: {"v":"端"} + +data: {"v":"通过"} + +data: {"v":"共享"} + +data: {"v":"会话"} + +data: {"v":"ID"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"`"} + +data: {"v":"X"} + +data: {"v":"-S"} + +data: {"v":"ession"} + +data: {"v":"-"} + +data: {"v":"Id"} + +data: {"v":"`"} + +data: {"v":"头"} + +data: {"v":")"} + +data: {"v":"影响"} + +data: {"v":"已有"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"流"} + +data: {"v":"。"} + +data: {"v":"这"} + +data: {"v":"保持了"} + +data: {"v":"架构"} + +data: {"v":"简单"} + +data: {"v":"。\n"} + +data: {"v":" "} + +data: {"v":" -"} + +data: {"v":" **"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"("} + +data: {"v":"仅"} + +data: {"v":"当"} + +data: {"v":"频繁"} + +data: {"v":"双向"} + +data: {"v":"交互"} + +data: {"v":")"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"只有在"} + +data: {"v":"要求"} + +data: {"v":"全"} + +data: {"v":"双"} + +data: {"v":"工"} + +data: {"v":"毫"} + +data: {"v":"秒"} + +data: {"v":"级"} + +data: {"v":"交互"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"实时"} + +data: {"v":"对话"} + +data: {"v":"且"} + +data: {"v":"用户"} + +data: {"v":"频繁"} + +data: {"v":"插"} + +data: {"v":"话"} + +data: {"v":")"} + +data: {"v":"时才"} + +data: {"v":"引入"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":","} + +data: {"v":"但"} + +data: {"v":"必须"} + +data: {"v":"自行"} + +data: {"v":"实现"} + +data: {"v":"上述"} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":"亲和"} + +data: {"v":"、"} + +data: {"v":"断"} + +data: {"v":"线"} + +data: {"v":"恢复"} + +data: {"v":"和"} + +data: {"v":"监控"} + +data: {"v":"。\n"} + +data: {"v":"4"} + +data: {"v":"."} + +data: {"v":" **"} + +data: {"v":"后端"} + +data: {"v":"内部"} + +data: {"v":"通信"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":"引擎"} + +data: {"v":"与"} + +data: {"v":"网关"} + +data: {"v":"之间"} + +data: {"v":"可采用"} + +data: {"v":"g"} + +data: {"v":"R"} + +data: {"v":"PC"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"("} + +data: {"v":"基于"} + +data: {"v":"HTTP"} + +data: {"v":"/"} + +data: {"v":"2"} + +data: {"v":"),"} + +data: {"v":"利用"} + +data: {"v":"其"} + +data: {"v":"内置"} + +data: {"v":"流"} + +data: {"v":"控"} + +data: {"v":"和"} + +data: {"v":"双向"} + +data: {"v":"特性"} + +data: {"v":","} + +data: {"v":"但"} + +data: {"v":"对外"} + +data: {"v":"层"} + +data: {"v":"仍"} + +data: {"v":"可通过"} + +data: {"v":"协议"} + +data: {"v":"适配"} + +data: {"v":"器"} + +data: {"v":"转换为"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"。\n\n"} + +data: {"v":"**"} + +data: {"v":"总结"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"对于"} + +data: {"v":"绝大多数"} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"输出"} + +data: {"v":"场景"} + +data: {"v":","} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"凭借"} + +data: {"v":"其"} + +data: {"v":"简单"} + +data: {"v":"、"} + +data: {"v":"可靠"} + +data: {"v":"、"} + +data: {"v":"易"} + +data: {"v":"观测"} + +data: {"v":"、"} + +data: {"v":"高"} + +data: {"v":"兼容"} + +data: {"v":"性的"} + +data: {"v":"优势"} + +data: {"v":","} + +data: {"v":"应"} + +data: {"v":"作为"} + +data: {"v":"标准"} + +data: {"v":"选"} + +data: {"v":"型"} + +data: {"v":"。"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"仅在"} + +data: {"v":"需要"} + +data: {"v":"复杂"} + +data: {"v":"双向"} + +data: {"v":"实时"} + +data: {"v":"控制"} + +data: {"v":"且"} + +data: {"v":"能"} + +data: {"v":"承担"} + +data: {"v":"额外"} + +data: {"v":"工程"} + +data: {"v":"成本"} + +data: {"v":"时"} + +data: {"v":"才有"} + +data: {"v":"竞争力"} + +data: {"v":"。"} + +data: {"v":"通过"} + +data: {"v":"分层"} + +data: {"v":"设计"} + +data: {"v":"("} + +data: {"v":"接入"} + +data: {"v":"层"} + +data: {"v":"+"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"会话"} + +data: {"v":"层"} + +data: {"v":"+"} + +data: {"v":"推理"} + +data: {"v":"引擎"} + +data: {"v":"层"} + +data: {"v":"),"} + +data: {"v":"可以"} + +data: {"v":"构建"} + +data: {"v":"高"} + +data: {"v":"可靠的"} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":"服务"} + +data: {"v":"。"} + +data: {"p":"response/fragments","v":[{"id":4,"type":"TIP","content":"本回答由 AI 生成,内容仅供参考,请仔细甄别。","style":"WARNING","hide_on_wip":true}]} + +data: {"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":2024},{"p":"quasi_status","v":"FINISHED"}]} + +data: {"p":"response/status","o":"SET","v":"FINISHED"} + +event: update_session +data: {"updated_at":1777485073.9337459} + +event: title +data: {"content":"SSE与WebSocket比较与架构建议"} + +event: close +data: {"click_behavior":"none","auto_resume":false} + diff --git a/tests/raw_stream_samples/longtext-deepseek-v4-pro-20260429/meta.json b/tests/raw_stream_samples/longtext-deepseek-v4-pro-20260429/meta.json new file mode 100644 index 0000000..1db9015 --- /dev/null +++ b/tests/raw_stream_samples/longtext-deepseek-v4-pro-20260429/meta.json @@ -0,0 +1,37 @@ +{ + "sample_id": "longtext-deepseek-v4-pro-20260429", + "captured_at_utc": "2026-04-29T17:52:45Z", + "source": "admin/dev/raw-samples/capture", + "request": { + "messages": [ + { + "content": "请写一篇1200字中文说明:比较SSE与WebSocket在AI推理流式输出中的可靠性、断线恢复、负载均衡、代理兼容性、成本和可观测性,并给出分层架构建议。", + "role": "user" + } + ], + "model": "deepseek-v4-pro", + "stream": true + }, + "capture": { + "label": "deepseek_upload_file", + "url": "https://chat.deepseek.com/api/v0/file/upload_file", + "status_code": 200, + "response_bytes": 55354, + "rounds": [ + { + "label": "deepseek_upload_file", + "url": "https://chat.deepseek.com/api/v0/file/upload_file", + "status_code": 200, + "response_bytes": 780 + }, + { + "label": "deepseek_completion", + "url": "https://chat.deepseek.com/api/v0/chat/completion", + "status_code": 200, + "response_bytes": 54573 + } + ], + "contains_finished_token": true, + "finished_token_count": 2 + } +} diff --git a/tests/raw_stream_samples/longtext-deepseek-v4-pro-20260429/upstream.stream.sse b/tests/raw_stream_samples/longtext-deepseek-v4-pro-20260429/upstream.stream.sse new file mode 100644 index 0000000..8b14272 --- /dev/null +++ b/tests/raw_stream_samples/longtext-deepseek-v4-pro-20260429/upstream.stream.sse @@ -0,0 +1,5230 @@ +{"code":0,"msg":"","data":{"biz_code":0,"biz_msg":"","biz_data":{"id":"file-9c8ae986-75f7-4611-9956-5e1b502f3ec2","status":"SUCCESS","file_name":"history.txt","from_share":false,"file_size":732,"model_kind":"NORMAL","token_usage":145,"error_code":null,"inserted_at":1777485076.42,"updated_at":1777485076.42,"signed_path":"/file?file_id=9c8ae986-75f7-4611-9956-5e1b502f3ec2&state=a1REa2AdO8JmDuxMFiUTPJfpiyY4ie2weyUpYxfvEOrk5lxUCZifpRw9toZAEzn3DAjkgbR6blgZf41KLkHBKwwrcYTIjfxTRKijDqjEfguis03yddpuVrii6keG4%2BXIlcLAsyZG3qcGhfTGVZhsr%2BRl17J%2BcnT9roslhxBcEy4rthFJVMWUI%2BSHjuo2gLEUDfvMfULQ1gSLVGtr%2Fpq%2FcNPCPSxZapIQv04ZVmJLcdbzRkz%2Bb%2BxM5RWUIPujp%2B3ke1WDa3%2B6S4pP0Pv%2BAJ0MFUjQsloUwO4AsJ8YhGBFWg8Ehe1b2yt1N%2Fi%2BIjLRPt5xiNmALcJJXIY%3D","is_image":false,"audit_result":null}}} +event: ready +data: {"request_message_id":1,"response_message_id":2,"model_type":"expert"} + +event: update_session +data: {"updated_at":1777485078.551796} + +data: {"v":{"response":{"message_id":2,"parent_id":1,"model":"","role":"ASSISTANT","thinking_enabled":true,"ban_edit":false,"ban_regenerate":false,"status":"WIP","incomplete_message":null,"accumulated_token_usage":0,"feedback":null,"inserted_at":1777485078.542825,"search_enabled":false,"fragments":[{"id":2,"type":"THINK","content":"我们","elapsed_secs":null,"references":[],"stage_id":1}],"conversation_mode":"DEFAULT","has_pending_fragment":false,"auto_continue":false}}} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"被"} + +data: {"v":"要求"} + +data: {"v":"写"} + +data: {"v":"一篇"} + +data: {"v":"120"} + +data: {"v":"0"} + +data: {"v":"字"} + +data: {"v":"的中"} + +data: {"v":"文"} + +data: {"v":"说明"} + +data: {"v":","} + +data: {"v":"比较"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"与"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"在"} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"输出"} + +data: {"v":"中的"} + +data: {"v":"可靠性"} + +data: {"v":"、"} + +data: {"v":"断"} + +data: {"v":"线"} + +data: {"v":"恢复"} + +data: {"v":"、"} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":"、"} + +data: {"v":"代理"} + +data: {"v":"兼容"} + +data: {"v":"性"} + +data: {"v":"、"} + +data: {"v":"成本和"} + +data: {"v":"可"} + +data: {"v":"观测"} + +data: {"v":"性"} + +data: {"v":","} + +data: {"v":"并"} + +data: {"v":"给出"} + +data: {"v":"分层"} + +data: {"v":"架构"} + +data: {"v":"建议"} + +data: {"v":"。"} + +data: {"v":"思考"} + +data: {"v":"过程"} + +data: {"v":"要求"} + +data: {"v":"详尽"} + +data: {"v":","} + +data: {"v":"但"} + +data: {"v":"最终"} + +data: {"v":"输出"} + +data: {"v":"是"} + +data: {"v":"给"} + +data: {"v":"用户的"} + +data: {"v":"直接"} + +data: {"v":"回答"} + +data: {"v":"。"} + +data: {"v":"助手"} + +data: {"v":"应该"} + +data: {"v":"输出"} + +data: {"v":"一篇"} + +data: {"v":"说明"} + +data: {"v":"文章"} + +data: {"v":"。"} + +data: {"v":"需要"} + +data: {"v":"包含"} + +data: {"v":"这些"} + +data: {"v":"方面的"} + +data: {"v":"比较"} + +data: {"v":","} + +data: {"v":"以及"} + +data: {"v":"分层"} + +data: {"v":"架构"} + +data: {"v":"建议"} + +data: {"v":"。"} + +data: {"v":"注意"} + +data: {"v":"字数"} + +data: {"v":"要求"} + +data: {"v":":"} + +data: {"v":"120"} + +data: {"v":"0"} + +data: {"v":"字"} + +data: {"v":"左右"} + +data: {"v":"。"} + +data: {"v":"可以"} + +data: {"v":"起草"} + +data: {"v":"内容"} + +data: {"v":"。\n\n"} + +data: {"v":"首先"} + +data: {"v":","} + +data: {"v":"理解"} + +data: {"v":"两种"} + +data: {"v":"技术"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"("} + +data: {"v":"Server"} + +data: {"v":"-S"} + +data: {"v":"ent"} + +data: {"v":" Events"} + +data: {"v":")"} + +data: {"v":"是基于"} + +data: {"v":"HTTP"} + +data: {"v":"的单"} + +data: {"v":"向"} + +data: {"v":"流"} + +data: {"v":","} + +data: {"v":"客户端"} + +data: {"v":"通过"} + +data: {"v":"Event"} + +data: {"v":"Source"} + +data: {"v":" API"} + +data: {"v":"接收"} + +data: {"v":"数据"} + +data: {"v":";"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"是全"} + +data: {"v":"双"} + +data: {"v":"工"} + +data: {"v":"通信"} + +data: {"v":"协议"} + +data: {"v":"。"} + +data: {"v":"在"} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"输出"} + +data: {"v":"场景"} + +data: {"v":","} + +data: {"v":"通常"} + +data: {"v":"是由"} + +data: {"v":"服务器"} + +data: {"v":"向"} + +data: {"v":"客户端"} + +data: {"v":"推送"} + +data: {"v":"生成的"} + +data: {"v":"token"} + +data: {"v":"或"} + +data: {"v":"结果"} + +data: {"v":","} + +data: {"v":"客户端"} + +data: {"v":"只是"} + +data: {"v":"接收"} + +data: {"v":","} + +data: {"v":"不需要"} + +data: {"v":"发送"} + +data: {"v":"太多"} + +data: {"v":"数据"} + +data: {"v":"("} + +data: {"v":"除了"} + +data: {"v":"初始"} + +data: {"v":"请求"} + +data: {"v":")。"} + +data: {"v":"所以"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"似乎"} + +data: {"v":"更"} + +data: {"v":"自然"} + +data: {"v":","} + +data: {"v":"但"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"也可"} + +data: {"v":"用"} + +data: {"v":"。"} + +data: {"v":"比较"} + +data: {"v":"可靠性"} + +data: {"v":"、"} + +data: {"v":"断"} + +data: {"v":"线"} + +data: {"v":"恢复"} + +data: {"v":"、"} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":"、"} + +data: {"v":"代理"} + +data: {"v":"兼容"} + +data: {"v":"性"} + +data: {"v":"、"} + +data: {"v":"成本"} + +data: {"v":"、"} + +data: {"v":"可"} + +data: {"v":"观测"} + +data: {"v":"性"} + +data: {"v":";"} + +data: {"v":"再"} + +data: {"v":"给出"} + +data: {"v":"分层"} + +data: {"v":"架构"} + +data: {"v":"建议"} + +data: {"v":"。\n\n"} + +data: {"v":"分析"} + +data: {"v":"每"} + +data: {"v":"一点"} + +data: {"v":":\n\n"} + +data: {"v":"1"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"可靠性"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"基于"} + +data: {"v":"HTTP"} + +data: {"v":","} + +data: {"v":"利用"} + +data: {"v":"HTTP"} + +data: {"v":"的"} + +data: {"v":"自动"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"机制"} + +data: {"v":"("} + +data: {"v":"Event"} + +data: {"v":"Source"} + +data: {"v":" API"} + +data: {"v":"会自动"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"并"} + +data: {"v":"发送"} + +data: {"v":"Last"} + +data: {"v":"-"} + +data: {"v":"Event"} + +data: {"v":"-ID"} + +data: {"v":"),"} + +data: {"v":"但"} + +data: {"v":"只有"} + +data: {"v":"单向"} + +data: {"v":","} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"时"} + +data: {"v":"可能需要"} + +data: {"v":"重新"} + +data: {"v":"发送"} + +data: {"v":"请求"} + +data: {"v":"?"} + +data: {"v":"如果"} + +data: {"v":"连接"} + +data: {"v":"断开"} + +data: {"v":","} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"客户端"} + +data: {"v":"会自动"} + +data: {"v":"尝试"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":","} + +data: {"v":"并"} + +data: {"v":"可以"} + +data: {"v":"发送"} + +data: {"v":"上次"} + +data: {"v":"接收"} + +data: {"v":"的事件"} + +data: {"v":"ID"} + +data: {"v":","} + +data: {"v":"服务器"} + +data: {"v":"可以从"} + +data: {"v":"该"} + +data: {"v":"ID"} + +data: {"v":"后"} + +data: {"v":"继续"} + +data: {"v":"发送"} + +data: {"v":"。"} + +data: {"v":"但"} + +data: {"v":"需要"} + +data: {"v":"服务器"} + +data: {"v":"端"} + +data: {"v":"支持"} + +data: {"v":"从"} + +data: {"v":"某个"} + +data: {"v":"事件"} + +data: {"v":"ID"} + +data: {"v":"恢复"} + +data: {"v":","} + +data: {"v":"维护"} + +data: {"v":"会话"} + +data: {"v":"。"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"需要"} + +data: {"v":"自己"} + +data: {"v":"实现"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"和"} + +data: {"v":"恢复"} + +data: {"v":"逻辑"} + +data: {"v":","} + +data: {"v":"比较"} + +data: {"v":"灵活"} + +data: {"v":"但"} + +data: {"v":"复杂"} + +data: {"v":"。"} + +data: {"v":"可靠性"} + +data: {"v":"方面"} + +data: {"v":","} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"因为有"} + +data: {"v":"标准"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"可能"} + +data: {"v":"更"} + +data: {"v":"简单"} + +data: {"v":"可靠"} + +data: {"v":"。"} + +data: {"v":"但"} + +data: {"v":"也要"} + +data: {"v":"考虑"} + +data: {"v":"网络"} + +data: {"v":"中断"} + +data: {"v":"后"} + +data: {"v":"消息"} + +data: {"v":"丢失"} + +data: {"v":","} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"的"} + +data: {"v":"自动"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"和"} + +data: {"v":"事件"} + +data: {"v":"ID"} + +data: {"v":"可以"} + +data: {"v":"做到"} + +data: {"v":"精确"} + +data: {"v":"恢复"} + +data: {"v":"("} + +data: {"v":"如果"} + +data: {"v":"服务器"} + +data: {"v":"记录"} + +data: {"v":")。"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"可以"} + +data: {"v":"双向"} + +data: {"v":"确认"} + +data: {"v":","} + +data: {"v":"实现"} + +data: {"v":"更"} + +data: {"v":"可靠的"} + +data: {"v":"传输"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"通过"} + +data: {"v":"ACK"} + +data: {"v":"),"} + +data: {"v":"但"} + +data: {"v":"需要"} + +data: {"v":"更多"} + +data: {"v":"开发"} + +data: {"v":"。\n\n"} + +data: {"v":"2"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"断"} + +data: {"v":"线"} + +data: {"v":"恢复"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"的"} + +data: {"v":"Event"} + +data: {"v":"Source"} + +data: {"v":"有"} + +data: {"v":"内置"} + +data: {"v":"的重"} + +data: {"v":"连"} + +data: {"v":"机制"} + +data: {"v":","} + +data: {"v":"并"} + +data: {"v":"可通过"} + +data: {"v":"Last"} + +data: {"v":"-"} + +data: {"v":"Event"} + +data: {"v":"-ID"} + +data: {"v":"实现"} + +data: {"v":"断"} + +data: {"v":"点"} + +data: {"v":"续"} + +data: {"v":"传"} + +data: {"v":"。"} + +data: {"v":"服务器"} + +data: {"v":"需要"} + +data: {"v":"根据"} + +data: {"v":"该"} + +data: {"v":"ID"} + +data: {"v":"缓存"} + +data: {"v":"或"} + +data: {"v":"重"} + +data: {"v":"放"} + +data: {"v":"事件"} + +data: {"v":"。"} + +data: {"v":"这对于"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":"很重要"} + +data: {"v":","} + +data: {"v":"因为"} + +data: {"v":"一旦"} + +data: {"v":"断开"} + +data: {"v":","} + +data: {"v":"客户端"} + +data: {"v":"可以"} + +data: {"v":"继续"} + +data: {"v":"接收"} + +data: {"v":"后续"} + +data: {"v":"token"} + +data: {"v":","} + +data: {"v":"而不"} + +data: {"v":"至于"} + +data: {"v":"丢失"} + +data: {"v":"中间"} + +data: {"v":"部分"} + +data: {"v":"。"} + +data: {"v":"但"} + +data: {"v":"需要"} + +data: {"v":"服务器"} + +data: {"v":"维护"} + +data: {"v":"事件"} + +data: {"v":"流"} + +data: {"v":"历史"} + +data: {"v":","} + +data: {"v":"可能"} + +data: {"v":"消耗"} + +data: {"v":"内存"} + +data: {"v":"。"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"需要"} + +data: {"v":"自定义"} + +data: {"v":"心跳"} + +data: {"v":"和"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":","} + +data: {"v":"还要"} + +data: {"v":"自己"} + +data: {"v":"设计"} + +data: {"v":"消息"} + +data: {"v":"ID"} + +data: {"v":"和"} + +data: {"v":"确认"} + +data: {"v":"机制"} + +data: {"v":"来"} + +data: {"v":"保证"} + +data: {"v":"不"} + +data: {"v":"丢失"} + +data: {"v":"。"} + +data: {"v":"所以"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"的"} + +data: {"v":"断"} + +data: {"v":"线"} + +data: {"v":"恢复"} + +data: {"v":"更"} + +data: {"v":"标准化"} + +data: {"v":","} + +data: {"v":"易于"} + +data: {"v":"实现"} + +data: {"v":"。\n\n"} + +data: {"v":"3"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"基于"} + +data: {"v":"HTTP"} + +data: {"v":"长"} + +data: {"v":"连接"} + +data: {"v":","} + +data: {"v":"可以通过"} + +data: {"v":"标准"} + +data: {"v":"HTTP"} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":"器"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"N"} + +data: {"v":"ginx"} + +data: {"v":"、"} + +data: {"v":"HA"} + +data: {"v":"Proxy"} + +data: {"v":")"} + +data: {"v":"分发"} + +data: {"v":","} + +data: {"v":"但"} + +data: {"v":"长"} + +data: {"v":"连接"} + +data: {"v":"可能"} + +data: {"v":"占用"} + +data: {"v":"连接"} + +data: {"v":"数"} + +data: {"v":"。"} + +data: {"v":"由于"} + +data: {"v":"是"} + +data: {"v":"单向"} + +data: {"v":"流"} + +data: {"v":","} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":"器"} + +data: {"v":"配置"} + +data: {"v":"相对"} + +data: {"v":"简单"} + +data: {"v":","} + +data: {"v":"只要"} + +data: {"v":"支持"} + +data: {"v":"HTTP"} + +data: {"v":"/"} + +data: {"v":"1"} + +data: {"v":"."} + +data: {"v":"1"} + +data: {"v":"长"} + +data: {"v":"连接"} + +data: {"v":"和"} + +data: {"v":"分"} + +data: {"v":"块"} + +data: {"v":"传输"} + +data: {"v":"。"} + +data: {"v":"但"} + +data: {"v":"如果有"} + +data: {"v":"反向"} + +data: {"v":"代理"} + +data: {"v":","} + +data: {"v":"可能需要"} + +data: {"v":"配置"} + +data: {"v":"超"} + +data: {"v":"时"} + +data: {"v":"。"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"则是"} + +data: {"v":"升级"} + +data: {"v":"协议"} + +data: {"v":","} + +data: {"v":"需要"} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":"器"} + +data: {"v":"支持"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"代理"} + +data: {"v":","} + +data: {"v":"需要"} + +data: {"v":"Up"} + +data: {"v":"grade"} + +data: {"v":"和"} + +data: {"v":"Connection"} + +data: {"v":"头"} + +data: {"v":","} + +data: {"v":"并且"} + +data: {"v":"需要"} + +data: {"v":"保持"} + +data: {"v":"连接"} + +data: {"v":","} + +data: {"v":"同样"} + +data: {"v":"占用"} + +data: {"v":"连接"} + +data: {"v":"。"} + +data: {"v":"两者的"} + +data: {"v":"区别"} + +data: {"v":"主要在"} + +data: {"v":"代理"} + +data: {"v":"兼容"} + +data: {"v":"性"} + +data: {"v":"上"} + +data: {"v":"。\n\n"} + +data: {"v":"4"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"代理"} + +data: {"v":"兼容"} + +data: {"v":"性"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"使用"} + +data: {"v":"普通"} + +data: {"v":"HTTP"} + +data: {"v":","} + +data: {"v":"大部分"} + +data: {"v":"HTTP"} + +data: {"v":"代理"} + +data: {"v":"和"} + +data: {"v":"CD"} + +data: {"v":"N"} + +data: {"v":"都能"} + +data: {"v":"透"} + +data: {"v":"传"} + +data: {"v":","} + +data: {"v":"只要"} + +data: {"v":"支持"} + +data: {"v":"分"} + +data: {"v":"块"} + +data: {"v":"传输"} + +data: {"v":"编码"} + +data: {"v":"和"} + +data: {"v":"不过"} + +data: {"v":"度"} + +data: {"v":"缓冲"} + +data: {"v":"。"} + +data: {"v":"某些"} + +data: {"v":"代理"} + +data: {"v":"可能会"} + +data: {"v":"缓冲"} + +data: {"v":"响应"} + +data: {"v":"体"} + +data: {"v":","} + +data: {"v":"影响"} + +data: {"v":"实时"} + +data: {"v":"性"} + +data: {"v":","} + +data: {"v":"可以通过"} + +data: {"v":"设置"} + +data: {"v":"响应"} + +data: {"v":"头"} + +data: {"v":"("} + +data: {"v":"X"} + +data: {"v":"-A"} + +data: {"v":"cc"} + +data: {"v":"el"} + +data: {"v":"-B"} + +data: {"v":"uff"} + +data: {"v":"ering"} + +data: {"v":":"} + +data: {"v":" no"} + +data: {"v":")"} + +data: {"v":"禁用"} + +data: {"v":"缓冲"} + +data: {"v":"。"} + +data: {"v":"但"} + +data: {"v":"总体"} + +data: {"v":"兼容"} + +data: {"v":"性好"} + +data: {"v":"。"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"需要"} + +data: {"v":"代理"} + +data: {"v":"明确"} + +data: {"v":"支持"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"协议"} + +data: {"v":"升级"} + +data: {"v":","} + +data: {"v":"否则"} + +data: {"v":"可能"} + +data: {"v":"被"} + +data: {"v":"拒绝"} + +data: {"v":"。"} + +data: {"v":"许多"} + +data: {"v":"老"} + +data: {"v":"代理"} + +data: {"v":"或"} + +data: {"v":"CD"} + +data: {"v":"N"} + +data: {"v":"不支持"} + +data: {"v":","} + +data: {"v":"需要"} + +data: {"v":"专门"} + +data: {"v":"配置"} + +data: {"v":"。"} + +data: {"v":"因此在"} + +data: {"v":"复杂"} + +data: {"v":"网络"} + +data: {"v":"环境下"} + +data: {"v":","} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"的"} + +data: {"v":"代理"} + +data: {"v":"兼容"} + +data: {"v":"性"} + +data: {"v":"明显"} + +data: {"v":"优于"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"。\n\n"} + +data: {"v":"5"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"成本"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"更"} + +data: {"v":"简单"} + +data: {"v":","} + +data: {"v":"开发"} + +data: {"v":"成本"} + +data: {"v":"低"} + +data: {"v":","} + +data: {"v":"使用"} + +data: {"v":"标准"} + +data: {"v":"HTTP"} + +data: {"v":","} + +data: {"v":"无需"} + +data: {"v":"特殊"} + +data: {"v":"库"} + +data: {"v":","} + +data: {"v":"客户端"} + +data: {"v":"使用"} + +data: {"v":"浏览器"} + +data: {"v":"内置"} + +data: {"v":"Event"} + +data: {"v":"Source"} + +data: {"v":"。"} + +data: {"v":"服务"} + +data: {"v":"端"} + +data: {"v":"实现"} + +data: {"v":"也"} + +data: {"v":"简单"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"设置"} + +data: {"v":"text"} + +data: {"v":"/"} + +data: {"v":"event"} + +data: {"v":"-stream"} + +data: {"v":","} + +data: {"v":"写"} + +data: {"v":"数据"} + +data: {"v":")。"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"需要使用"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"库"} + +data: {"v":","} + +data: {"v":"管理"} + +data: {"v":"心跳"} + +data: {"v":"、"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"、"} + +data: {"v":"消息"} + +data: {"v":"序列"} + +data: {"v":"化"} + +data: {"v":"等"} + +data: {"v":","} + +data: {"v":"开发"} + +data: {"v":"成本"} + +data: {"v":"较高"} + +data: {"v":"。"} + +data: {"v":"资源"} + +data: {"v":"消耗"} + +data: {"v":"方面"} + +data: {"v":","} + +data: {"v":"两者"} + +data: {"v":"都是"} + +data: {"v":"长"} + +data: {"v":"连接"} + +data: {"v":","} + +data: {"v":"占用"} + +data: {"v":"连接"} + +data: {"v":"数"} + +data: {"v":"相近"} + +data: {"v":"。"} + +data: {"v":"但"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"可以"} + +data: {"v":"更容易"} + +data: {"v":"利用"} + +data: {"v":"HTTP"} + +data: {"v":"/"} + +data: {"v":"2"} + +data: {"v":"的多"} + +data: {"v":"路"} + +data: {"v":"复用"} + +data: {"v":"("} + +data: {"v":"虽然"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"规范"} + +data: {"v":"基于"} + +data: {"v":"HTTP"} + +data: {"v":"/"} + +data: {"v":"1"} + +data: {"v":"."} + +data: {"v":"1"} + +data: {"v":","} + +data: {"v":"但在"} + +data: {"v":"HTTP"} + +data: {"v":"/"} + +data: {"v":"2"} + +data: {"v":"下"} + +data: {"v":"也可"} + +data: {"v":"使用"} + +data: {"v":","} + +data: {"v":"但"} + +data: {"v":"Event"} + +data: {"v":"Source"} + +data: {"v":" API"} + +data: {"v":"可能"} + +data: {"v":"有限"} + +data: {"v":"制"} + +data: {"v":"),"} + +data: {"v":"而"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"在"} + +data: {"v":"HTTP"} + +data: {"v":"/"} + +data: {"v":"2"} + +data: {"v":"下"} + +data: {"v":"可通过"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":" over"} + +data: {"v":" HTTP"} + +data: {"v":"/"} + +data: {"v":"2"} + +data: {"v":"。"} + +data: {"v":"整体"} + +data: {"v":"成本"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"更低"} + +data: {"v":"。\n\n"} + +data: {"v":"6"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"可"} + +data: {"v":"观测"} + +data: {"v":"性"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"通过"} + +data: {"v":"HTTP"} + +data: {"v":","} + +data: {"v":"可以使用"} + +data: {"v":"标准"} + +data: {"v":"HTTP"} + +data: {"v":"日志"} + +data: {"v":"、"} + +data: {"v":"监控"} + +data: {"v":"工具"} + +data: {"v":"、"} + +data: {"v":"分布式"} + +data: {"v":"追踪"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"通过"} + +data: {"v":"头"} + +data: {"v":"传递"} + +data: {"v":"trace"} + +data: {"v":" id"} + +data: {"v":")。"} + +data: {"v":"因为"} + +data: {"v":"走"} + +data: {"v":"HTTP"} + +data: {"v":","} + +data: {"v":"可以"} + +data: {"v":"轻松"} + +data: {"v":"集成"} + +data: {"v":"现有的"} + +data: {"v":"API"} + +data: {"v":"网关"} + +data: {"v":"监控"} + +data: {"v":"。"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"则需要"} + +data: {"v":"专门的"} + +data: {"v":"监控"} + +data: {"v":","} + +data: {"v":"因为"} + +data: {"v":"连接"} + +data: {"v":"建立"} + +data: {"v":"后"} + +data: {"v":"消息"} + +data: {"v":"是"} + +data: {"v":"二进制"} + +data: {"v":"或"} + +data: {"v":"文本"} + +data: {"v":"帧"} + +data: {"v":","} + +data: {"v":"需要"} + +data: {"v":"应用"} + +data: {"v":"层"} + +data: {"v":"日志"} + +data: {"v":"记录"} + +data: {"v":"消息"} + +data: {"v":","} + +data: {"v":"不易"} + +data: {"v":"用"} + +data: {"v":"标准"} + +data: {"v":"HTTP"} + +data: {"v":"中间"} + +data: {"v":"件"} + +data: {"v":"观测"} + +data: {"v":"。"} + +data: {"v":"一些"} + +data: {"v":"API"} + +data: {"v":"网关"} + +data: {"v":"支持"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":","} + +data: {"v":"但"} + +data: {"v":"不如"} + +data: {"v":"HTTP"} + +data: {"v":"成熟"} + +data: {"v":"。"} + +data: {"v":"所以"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"的可"} + +data: {"v":"观测"} + +data: {"v":"性"} + +data: {"v":"更好"} + +data: {"v":"。\n\n"} + +data: {"v":"总结"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"在"} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"输出"} + +data: {"v":"场景"} + +data: {"v":"("} + +data: {"v":"服务器"} + +data: {"v":"到"} + +data: {"v":"客户端"} + +data: {"v":"单向"} + +data: {"v":"流"} + +data: {"v":")"} + +data: {"v":"更适合"} + +data: {"v":","} + +data: {"v":"因其"} + +data: {"v":"简单"} + +data: {"v":"、"} + +data: {"v":"可靠"} + +data: {"v":"、"} + +data: {"v":"易"} + +data: {"v":"恢复"} + +data: {"v":"、"} + +data: {"v":"兼容"} + +data: {"v":"性好"} + +data: {"v":"、"} + +data: {"v":"成本"} + +data: {"v":"低"} + +data: {"v":"、"} + +data: {"v":"可"} + +data: {"v":"观测"} + +data: {"v":"性强"} + +data: {"v":"。"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"更适合"} + +data: {"v":"需要"} + +data: {"v":"双向"} + +data: {"v":"交互"} + +data: {"v":"的场景"} + +data: {"v":","} + +data: {"v":"如"} + +data: {"v":"实时"} + +data: {"v":"对话"} + +data: {"v":"代理"} + +data: {"v":"、"} + +data: {"v":"客户端"} + +data: {"v":"可以"} + +data: {"v":"中断"} + +data: {"v":"或"} + +data: {"v":"修改"} + +data: {"v":"生成"} + +data: {"v":"参数"} + +data: {"v":"。"} + +data: {"v":"但"} + +data: {"v":"很多"} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"场景"} + +data: {"v":"仅"} + +data: {"v":"需"} + +data: {"v":"服务器"} + +data: {"v":"推送"} + +data: {"v":","} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"足够"} + +data: {"v":"。\n\n"} + +data: {"v":"分层"} + +data: {"v":"架构"} + +data: {"v":"建议"} + +data: {"v":":"} + +data: {"v":"可以"} + +data: {"v":"设计"} + +data: {"v":"一个"} + +data: {"v":"分层"} + +data: {"v":"架构"} + +data: {"v":","} + +data: {"v":"将"} + +data: {"v":"推理"} + +data: {"v":"服务"} + +data: {"v":"作为"} + +data: {"v":"核心"} + +data: {"v":","} + +data: {"v":"上层"} + +data: {"v":"加"} + +data: {"v":"一层"} + +data: {"v":"API"} + +data: {"v":"网关"} + +data: {"v":"或"} + +data: {"v":"stream"} + +data: {"v":"ing"} + +data: {"v":"服务"} + +data: {"v":"层"} + +data: {"v":","} + +data: {"v":"用于"} + +data: {"v":"管理"} + +data: {"v":"连接"} + +data: {"v":"、"} + +data: {"v":"认证"} + +data: {"v":"、"} + +data: {"v":"限"} + +data: {"v":"流"} + +data: {"v":"、"} + +data: {"v":"断"} + +data: {"v":"点"} + +data: {"v":"续"} + +data: {"v":"传"} + +data: {"v":"缓存"} + +data: {"v":"等"} + +data: {"v":"。"} + +data: {"v":"例如"} + +data: {"v":":\n\n"} + +data: {"v":"-"} + +data: {"v":" "} + +data: {"v":"客户端"} + +data: {"v":"层"} + +data: {"v":":"} + +data: {"v":"Web"} + +data: {"v":"/"} + +data: {"v":"移动"} + +data: {"v":"端"} + +data: {"v":","} + +data: {"v":"使用"} + +data: {"v":"Event"} + +data: {"v":"Source"} + +data: {"v":"或"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"("} + +data: {"v":"根据"} + +data: {"v":"场景"} + +data: {"v":")。\n"} + +data: {"v":"-"} + +data: {"v":" "} + +data: {"v":"接入"} + +data: {"v":"层"} + +data: {"v":":"} + +data: {"v":"API"} + +data: {"v":"网关"} + +data: {"v":"或"} + +data: {"v":"反向"} + +data: {"v":"代理"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"N"} + +data: {"v":"ginx"} + +data: {"v":"),"} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":","} + +data: {"v":"SSL"} + +data: {"v":"终止"} + +data: {"v":","} + +data: {"v":"支持"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"长"} + +data: {"v":"连接"} + +data: {"v":"配置"} + +data: {"v":"。\n"} + +data: {"v":"-"} + +data: {"v":" "} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"服务"} + +data: {"v":"层"} + +data: {"v":":"} + +data: {"v":"负责"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"连接"} + +data: {"v":"管理"} + +data: {"v":"、"} + +data: {"v":"会话"} + +data: {"v":"管理"} + +data: {"v":"、"} + +data: {"v":"事件"} + +data: {"v":"缓存"} + +data: {"v":"("} + +data: {"v":"用于"} + +data: {"v":"断"} + +data: {"v":"线"} + +data: {"v":"恢复"} + +data: {"v":")、"} + +data: {"v":"消息"} + +data: {"v":"路由"} + +data: {"v":"等"} + +data: {"v":"。\n"} + +data: {"v":"-"} + +data: {"v":" "} + +data: {"v":"推理"} + +data: {"v":"引擎"} + +data: {"v":"层"} + +data: {"v":":"} + +data: {"v":"实际的"} + +data: {"v":"AI"} + +data: {"v":"模型"} + +data: {"v":"推理"} + +data: {"v":","} + +data: {"v":"产生"} + +data: {"v":"token"} + +data: {"v":"流"} + +data: {"v":","} + +data: {"v":"通过"} + +data: {"v":"回调"} + +data: {"v":"或者"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"接口"} + +data: {"v":"将"} + +data: {"v":"token"} + +data: {"v":"推"} + +data: {"v":"送到"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"服务"} + +data: {"v":"层"} + +data: {"v":"。\n"} + +data: {"v":"-"} + +data: {"v":" "} + +data: {"v":"可"} + +data: {"v":"观测"} + +data: {"v":"性"} + +data: {"v":"层"} + +data: {"v":":"} + +data: {"v":"贯穿"} + +data: {"v":"各"} + +data: {"v":"层"} + +data: {"v":","} + +data: {"v":"使用"} + +data: {"v":"结构化"} + +data: {"v":"日志"} + +data: {"v":"、"} + +data: {"v":"链路"} + +data: {"v":"追踪"} + +data: {"v":"、"} + +data: {"v":"指标"} + +data: {"v":"监控"} + +data: {"v":"。\n\n"} + +data: {"v":"建议"} + +data: {"v":"优先"} + +data: {"v":"使用"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"作为"} + +data: {"v":"默认"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"协议"} + +data: {"v":","} + +data: {"v":"只有在"} + +data: {"v":"需要"} + +data: {"v":"客户端"} + +data: {"v":"主动"} + +data: {"v":"发送"} + +data: {"v":"控制"} + +data: {"v":"指令"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"停止"} + +data: {"v":"生成"} + +data: {"v":"、"} + +data: {"v":"修改"} + +data: {"v":"参数"} + +data: {"v":")"} + +data: {"v":"时才"} + +data: {"v":"考虑"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":","} + +data: {"v":"或者"} + +data: {"v":"采用"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":" +"} + +data: {"v":" HTTP"} + +data: {"v":"请求"} + +data: {"v":"的"} + +data: {"v":"混合"} + +data: {"v":"模式"} + +data: {"v":"("} + +data: {"v":"即"} + +data: {"v":"用"} + +data: {"v":"POST"} + +data: {"v":"请求"} + +data: {"v":"开始"} + +data: {"v":"推理"} + +data: {"v":","} + +data: {"v":"用"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"接收"} + +data: {"v":"流"} + +data: {"v":","} + +data: {"v":"用"} + +data: {"v":"另一个"} + +data: {"v":"HTTP"} + +data: {"v":"请求"} + +data: {"v":"发送"} + +data: {"v":"控制"} + +data: {"v":"指令"} + +data: {"v":")。"} + +data: {"v":"这种"} + +data: {"v":"架构"} + +data: {"v":"清晰"} + +data: {"v":","} + +data: {"v":"各"} + +data: {"v":"层"} + +data: {"v":"职责"} + +data: {"v":"分明"} + +data: {"v":"。\n\n"} + +data: {"v":"现在"} + +data: {"v":"组合"} + +data: {"v":"成"} + +data: {"v":"120"} + +data: {"v":"0"} + +data: {"v":"字的"} + +data: {"v":"文章"} + +data: {"v":"。"} + +data: {"v":"大约"} + +data: {"v":"需要"} + +data: {"v":"6"} + +data: {"v":"-"} + +data: {"v":"7"} + +data: {"v":"段"} + +data: {"v":"。"} + +data: {"v":"可以"} + +data: {"v":"这样"} + +data: {"v":"组织"} + +data: {"v":":\n\n"} + +data: {"v":"-"} + +data: {"v":" "} + +data: {"v":"引言"} + +data: {"v":":"} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"输出"} + +data: {"v":"需求"} + +data: {"v":","} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"和"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"是"} + +data: {"v":"两种"} + +data: {"v":"主流"} + +data: {"v":"方案"} + +data: {"v":","} + +data: {"v":"本文"} + +data: {"v":"比较"} + +data: {"v":"六"} + +data: {"v":"方面"} + +data: {"v":"并"} + +data: {"v":"给出"} + +data: {"v":"架构"} + +data: {"v":"建议"} + +data: {"v":"。\n"} + +data: {"v":"-"} + +data: {"v":" "} + +data: {"v":"可靠性"} + +data: {"v":"对比"} + +data: {"v":":"} + +data: {"v":"描述"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"内置"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"和"} + +data: {"v":"事件"} + +data: {"v":"ID"} + +data: {"v":","} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"需"} + +data: {"v":"自"} + +data: {"v":"建"} + +data: {"v":"。\n"} + +data: {"v":"-"} + +data: {"v":" "} + +data: {"v":"断"} + +data: {"v":"线"} + +data: {"v":"恢复"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"的"} + +data: {"v":"Last"} + +data: {"v":"-"} + +data: {"v":"Event"} + +data: {"v":"-ID"} + +data: {"v":"机制"} + +data: {"v":","} + +data: {"v":"服务器"} + +data: {"v":"缓存"} + +data: {"v":";"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"手工"} + +data: {"v":"恢复"} + +data: {"v":"。\n"} + +data: {"v":"-"} + +data: {"v":" "} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":"与"} + +data: {"v":"代理"} + +data: {"v":"兼容"} + +data: {"v":"性"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"使用"} + +data: {"v":"HTTP"} + +data: {"v":","} + +data: {"v":"更"} + +data: {"v":"兼容"} + +data: {"v":";"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"需"} + +data: {"v":"升级"} + +data: {"v":"支持"} + +data: {"v":"。\n"} + +data: {"v":"-"} + +data: {"v":" "} + +data: {"v":"成本"} + +data: {"v":"与"} + +data: {"v":"可"} + +data: {"v":"观测"} + +data: {"v":"性"} + +data: {"v":":"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"开发"} + +data: {"v":"维护"} + +data: {"v":"成本"} + +data: {"v":"低"} + +data: {"v":","} + +data: {"v":"可"} + +data: {"v":"观测"} + +data: {"v":"性好"} + +data: {"v":";"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"复杂"} + +data: {"v":"。\n"} + +data: {"v":"-"} + +data: {"v":" "} + +data: {"v":"综合"} + +data: {"v":"建议"} + +data: {"v":"与"} + +data: {"v":"分层"} + +data: {"v":"架构"} + +data: {"v":":"} + +data: {"v":"推荐"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"为主"} + +data: {"v":","} + +data: {"v":"给出"} + +data: {"v":"分层"} + +data: {"v":"设计"} + +data: {"v":":"} + +data: {"v":"客户端"} + +data: {"v":"、"} + +data: {"v":"接入"} + +data: {"v":"层"} + +data: {"v":"、"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"服务"} + +data: {"v":"层"} + +data: {"v":"、"} + +data: {"v":"推理"} + +data: {"v":"引擎"} + +data: {"v":"层"} + +data: {"v":"、"} + +data: {"v":"可"} + +data: {"v":"观测"} + +data: {"v":"性"} + +data: {"v":","} + +data: {"v":"并"} + +data: {"v":"说明"} + +data: {"v":"各"} + +data: {"v":"层"} + +data: {"v":"职责"} + +data: {"v":"。"} + +data: {"v":"混合"} + +data: {"v":"模式"} + +data: {"v":"备"} + +data: {"v":"选"} + +data: {"v":"。\n\n"} + +data: {"v":"确保"} + +data: {"v":"字数"} + +data: {"v":"约"} + +data: {"v":"120"} + +data: {"v":"0"} + +data: {"v":"("} + +data: {"v":"中"} + +data: {"v":"文字"} + +data: {"v":"符"} + +data: {"v":"数"} + +data: {"v":")。"} + +data: {"v":"实际"} + +data: {"v":"写"} + +data: {"v":"时"} + +data: {"v":"控制"} + +data: {"v":"100"} + +data: {"v":"0"} + +data: {"v":"-"} + +data: {"v":"150"} + +data: {"v":"0"} + +data: {"v":"字"} + +data: {"v":"之间"} + +data: {"v":"。"} + +data: {"v":"无需"} + +data: {"v":"特别"} + +data: {"v":"精确"} + +data: {"v":","} + +data: {"v":"大致"} + +data: {"v":"即可"} + +data: {"v":"。"} + +data: {"v":"现在"} + +data: {"v":"写出"} + +data: {"v":"最终"} + +data: {"v":"回答"} + +data: {"v":"。"} + +data: {"p":"response/fragments/-1/elapsed_secs","o":"SET","v":49.301517849} + +data: {"p":"response/fragments","o":"APPEND","v":[{"id":3,"type":"RESPONSE","content":"在","references":[],"stage_id":1}]} + +data: {"p":"response/fragments/-1/content","v":"AI"} + +data: {"v":"推理"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"输出"} + +data: {"v":"场景"} + +data: {"v":"中"} + +data: {"v":","} + +data: {"v":"服务器"} + +data: {"v":"需"} + +data: {"v":"将"} + +data: {"v":"生成的"} + +data: {"v":"token"} + +data: {"v":"序列"} + +data: {"v":"实时"} + +data: {"v":"推"} + +data: {"v":"送给"} + +data: {"v":"客户端"} + +data: {"v":","} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"("} + +data: {"v":"Server"} + +data: {"v":"-S"} + +data: {"v":"ent"} + +data: {"v":" Events"} + +data: {"v":")"} + +data: {"v":"和"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"是"} + +data: {"v":"两种"} + +data: {"v":"主"} + +data: {"v":"流的"} + +data: {"v":"通信"} + +data: {"v":"方案"} + +data: {"v":"。"} + +data: {"v":"本文"} + +data: {"v":"从"} + +data: {"v":"可靠性"} + +data: {"v":"、"} + +data: {"v":"断"} + +data: {"v":"线"} + +data: {"v":"恢复"} + +data: {"v":"、"} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":"、"} + +data: {"v":"代理"} + +data: {"v":"兼容"} + +data: {"v":"性"} + +data: {"v":"、"} + +data: {"v":"成本和"} + +data: {"v":"可"} + +data: {"v":"观测"} + +data: {"v":"性"} + +data: {"v":"六个"} + +data: {"v":"维度"} + +data: {"v":"进行"} + +data: {"v":"对比"} + +data: {"v":","} + +data: {"v":"并"} + +data: {"v":"给出"} + +data: {"v":"分层"} + +data: {"v":"架构"} + +data: {"v":"建议"} + +data: {"v":"。\n\n"} + +data: {"v":"**"} + +data: {"v":"一"} + +data: {"v":"、"} + +data: {"v":"可靠性"} + +data: {"v":"**"} + +data: {"v":" \n"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"基于"} + +data: {"v":"HTTP"} + +data: {"v":"协议"} + +data: {"v":","} + +data: {"v":"继承了"} + +data: {"v":"HTTP"} + +data: {"v":"的"} + +data: {"v":"自动"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"机制"} + +data: {"v":"。"} + +data: {"v":"浏览器"} + +data: {"v":"端"} + +data: {"v":"Event"} + +data: {"v":"Source"} + +data: {"v":" API"} + +data: {"v":"在"} + +data: {"v":"连接"} + +data: {"v":"断开"} + +data: {"v":"后"} + +data: {"v":"会自动"} + +data: {"v":"发起"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":","} + +data: {"v":"并"} + +data: {"v":"可通过"} + +data: {"v":"`"} + +data: {"v":"Last"} + +data: {"v":"-"} + +data: {"v":"Event"} + +data: {"v":"-ID"} + +data: {"v":"`"} + +data: {"v":"头"} + +data: {"v":"告知"} + +data: {"v":"服务器"} + +data: {"v":"最后"} + +data: {"v":"接收"} + +data: {"v":"的事件"} + +data: {"v":"ID"} + +data: {"v":","} + +data: {"v":"服务器"} + +data: {"v":"据此"} + +data: {"v":"推送"} + +data: {"v":"后续"} + +data: {"v":"事件"} + +data: {"v":","} + +data: {"v":"实现"} + +data: {"v":"精确"} + +data: {"v":"的"} + +data: {"v":"断"} + +data: {"v":"点"} + +data: {"v":"续"} + +data: {"v":"传"} + +data: {"v":"。"} + +data: {"v":"这种"} + +data: {"v":"设计"} + +data: {"v":"对"} + +data: {"v":"开发者"} + +data: {"v":"透明"} + +data: {"v":","} + +data: {"v":"无需"} + +data: {"v":"额外"} + +data: {"v":"的心"} + +data: {"v":"跳"} + +data: {"v":"与"} + +data: {"v":"状态"} + +data: {"v":"管理"} + +data: {"v":"。"} + +data: {"v":" \n"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"本身"} + +data: {"v":"不"} + +data: {"v":"内置"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"与"} + +data: {"v":"消息"} + +data: {"v":"确认"} + +data: {"v":","} + +data: {"v":"断开"} + +data: {"v":"后"} + +data: {"v":"需"} + +data: {"v":"客户端"} + +data: {"v":"主动"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":","} + +data: {"v":"并"} + +data: {"v":"自行"} + +data: {"v":"实现"} + +data: {"v":"消息"} + +data: {"v":"ID"} + +data: {"v":"、"} + +data: {"v":"ACK"} + +data: {"v":"及"} + +data: {"v":"重"} + +data: {"v":"放"} + +data: {"v":"逻辑"} + +data: {"v":"。"} + +data: {"v":"虽然"} + +data: {"v":"可"} + +data: {"v":"设计"} + +data: {"v":"出"} + +data: {"v":"更"} + +data: {"v":"灵活的"} + +data: {"v":"可靠"} + +data: {"v":"传输"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"选择性"} + +data: {"v":"重"} + +data: {"v":"传"} + +data: {"v":"),"} + +data: {"v":"但"} + +data: {"v":"复杂性"} + +data: {"v":"显著"} + +data: {"v":"增加"} + +data: {"v":","} + +data: {"v":"易"} + +data: {"v":"引入"} + +data: {"v":"缺陷"} + +data: {"v":"。\n\n"} + +data: {"v":"**"} + +data: {"v":"二"} + +data: {"v":"、"} + +data: {"v":"断"} + +data: {"v":"线"} + +data: {"v":"恢复"} + +data: {"v":"**"} + +data: {"v":" \n"} + +data: {"v":"断"} + +data: {"v":"线"} + +data: {"v":"恢复"} + +data: {"v":"直接影响"} + +data: {"v":"用户体验"} + +data: {"v":"。"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"的"} + +data: {"v":"`"} + +data: {"v":"Last"} + +data: {"v":"-"} + +data: {"v":"Event"} + +data: {"v":"-ID"} + +data: {"v":"`"} + +data: {"v":"机制"} + +data: {"v":"天然"} + +data: {"v":"支持"} + +data: {"v":"增量"} + +data: {"v":"续"} + +data: {"v":"传"} + +data: {"v":":"} + +data: {"v":"服务器"} + +data: {"v":"只需"} + +data: {"v":"按"} + +data: {"v":"ID"} + +data: {"v":"缓存"} + +data: {"v":"已"} + +data: {"v":"生成"} + +data: {"v":"的事件"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"token"} + +data: {"v":"序列"} + +data: {"v":"),"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"时"} + +data: {"v":"跳过"} + +data: {"v":"已"} + +data: {"v":"确认"} + +data: {"v":"部分"} + +data: {"v":","} + +data: {"v":"直接"} + +data: {"v":"推送"} + +data: {"v":"后续"} + +data: {"v":"数据"} + +data: {"v":"。"} + +data: {"v":"这对"} + +data: {"v":"token"} + +data: {"v":"级别"} + +data: {"v":"输出"} + +data: {"v":"极为"} + +data: {"v":"友好"} + +data: {"v":","} + +data: {"v":"可"} + +data: {"v":"避免"} + +data: {"v":"重复"} + +data: {"v":"生成"} + +data: {"v":"或"} + +data: {"v":"丢失"} + +data: {"v":"中间"} + +data: {"v":"内容"} + +data: {"v":"。"} + +data: {"v":" \n"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"要实现"} + +data: {"v":"同等"} + +data: {"v":"效果"} + +data: {"v":","} + +data: {"v":"必须"} + +data: {"v":"自定义"} + +data: {"v":"序列"} + +data: {"v":"号"} + +data: {"v":"与"} + +data: {"v":"恢复"} + +data: {"v":"协议"} + +data: {"v":"。"} + +data: {"v":"若"} + +data: {"v":"服务"} + +data: {"v":"端"} + +data: {"v":"需"} + +data: {"v":"缓存"} + +data: {"v":"未"} + +data: {"v":"确认"} + +data: {"v":"消息"} + +data: {"v":","} + +data: {"v":"会使"} + +data: {"v":"无"} + +data: {"v":"状态"} + +data: {"v":"架构"} + +data: {"v":"变"} + +data: {"v":"重"} + +data: {"v":";"} + +data: {"v":"若"} + +data: {"v":"依赖"} + +data: {"v":"客户端"} + +data: {"v":"补"} + +data: {"v":"拉"} + +data: {"v":","} + +data: {"v":"则"} + +data: {"v":"增加"} + +data: {"v":"交互"} + +data: {"v":"次数"} + +data: {"v":"与"} + +data: {"v":"延迟"} + +data: {"v":"。"} + +data: {"v":"因此"} + +data: {"v":","} + +data: {"v":"在"} + +data: {"v":"典型的"} + +data: {"v":"单向"} + +data: {"v":"推"} + +data: {"v":"流"} + +data: {"v":"场景"} + +data: {"v":"中"} + +data: {"v":","} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"的"} + +data: {"v":"断"} + +data: {"v":"线"} + +data: {"v":"恢复"} + +data: {"v":"更"} + +data: {"v":"简单"} + +data: {"v":"可靠"} + +data: {"v":"。\n\n"} + +data: {"v":"**"} + +data: {"v":"三"} + +data: {"v":"、"} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":"**"} + +data: {"v":" \n"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"使用"} + +data: {"v":"标准"} + +data: {"v":"HTTP"} + +data: {"v":"长"} + +data: {"v":"连接"} + +data: {"v":","} + +data: {"v":"可"} + +data: {"v":"被"} + +data: {"v":"N"} + +data: {"v":"ginx"} + +data: {"v":"、"} + +data: {"v":"HA"} + +data: {"v":"Proxy"} + +data: {"v":"等"} + +data: {"v":"常规"} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":"器"} + +data: {"v":"直接"} + +data: {"v":"分发"} + +data: {"v":"。"} + +data: {"v":"只需"} + +data: {"v":"调整"} + +data: {"v":"超"} + +data: {"v":"时"} + +data: {"v":"配置"} + +data: {"v":"、"} + +data: {"v":"关闭"} + +data: {"v":"缓冲"} + +data: {"v":","} + +data: {"v":"即可"} + +data: {"v":"支撑"} + +data: {"v":"大量"} + +data: {"v":"长"} + +data: {"v":"连接"} + +data: {"v":"。"} + +data: {"v":"HTTP"} + +data: {"v":"/"} + +data: {"v":"2"} + +data: {"v":"下"} + +data: {"v":"还可"} + +data: {"v":"借助"} + +data: {"v":"多"} + +data: {"v":"路"} + +data: {"v":"复用"} + +data: {"v":"减少"} + +data: {"v":"连接"} + +data: {"v":"数"} + +data: {"v":"。"} + +data: {"v":" \n"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"通过"} + +data: {"v":"协议"} + +data: {"v":"升级"} + +data: {"v":"建立"} + +data: {"v":"全"} + +data: {"v":"双"} + +data: {"v":"工"} + +data: {"v":"连接"} + +data: {"v":","} + +data: {"v":"需要"} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":"器"} + +data: {"v":"显"} + +data: {"v":"式"} + +data: {"v":"支持"} + +data: {"v":"Up"} + +data: {"v":"grade"} + +data: {"v":"与"} + +data: {"v":"Connection"} + +data: {"v":"头"} + +data: {"v":","} + +data: {"v":"且"} + +data: {"v":"连接"} + +data: {"v":"建立"} + +data: {"v":"后"} + +data: {"v":"负载"} + +data: {"v":"均衡"} + +data: {"v":"器"} + +data: {"v":"通常"} + +data: {"v":"只能"} + +data: {"v":"维持"} + +data: {"v":"固定的"} + +data: {"v":"后端"} + +data: {"v":"映射"} + +data: {"v":","} + +data: {"v":"灵活性"} + +data: {"v":"略"} + +data: {"v":"低"} + +data: {"v":"。"} + +data: {"v":"两者"} + +data: {"v":"在"} + +data: {"v":"连接"} + +data: {"v":"资源"} + +data: {"v":"消耗"} + +data: {"v":"上"} + +data: {"v":"相近"} + +data: {"v":","} + +data: {"v":"但"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"部署"} + +data: {"v":"与"} + +data: {"v":"横向"} + +data: {"v":"扩展"} + +data: {"v":"更"} + +data: {"v":"便捷"} + +data: {"v":"。\n\n"} + +data: {"v":"**"} + +data: {"v":"四"} + +data: {"v":"、"} + +data: {"v":"代理"} + +data: {"v":"兼容"} + +data: {"v":"性"} + +data: {"v":"**"} + +data: {"v":" \n"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"的"} + +data: {"v":"流量"} + +data: {"v":"是"} + +data: {"v":"普通"} + +data: {"v":"HTTP"} + +data: {"v":"响应"} + +data: {"v":","} + +data: {"v":"绝大多数"} + +data: {"v":"企业"} + +data: {"v":"代理"} + +data: {"v":"、"} + +data: {"v":"CD"} + +data: {"v":"N"} + +data: {"v":"和"} + +data: {"v":"防火墙"} + +data: {"v":"均"} + +data: {"v":"能"} + +data: {"v":"透"} + +data: {"v":"传"} + +data: {"v":"。"} + +data: {"v":"仅"} + +data: {"v":"需"} + +data: {"v":"注意"} + +data: {"v":"部分"} + +data: {"v":"代理"} + +data: {"v":"可能"} + +data: {"v":"缓冲"} + +data: {"v":"数据"} + +data: {"v":","} + +data: {"v":"通过"} + +data: {"v":"设置"} + +data: {"v":"`"} + +data: {"v":"X"} + +data: {"v":"-A"} + +data: {"v":"cc"} + +data: {"v":"el"} + +data: {"v":"-B"} + +data: {"v":"uff"} + +data: {"v":"ering"} + +data: {"v":":"} + +data: {"v":" no"} + +data: {"v":"`"} + +data: {"v":"或"} + +data: {"v":"添加"} + +data: {"v":"限"} + +data: {"v":"时"} + +data: {"v":"头"} + +data: {"v":"即可"} + +data: {"v":"解决"} + +data: {"v":"。"} + +data: {"v":" \n"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"要求"} + +data: {"v":"中间"} + +data: {"v":"代理"} + +data: {"v":"理解"} + +data: {"v":"并"} + +data: {"v":"支持"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"协议"} + +data: {"v":","} + +data: {"v":"许多"} + +data: {"v":"老"} + +data: {"v":"式"} + +data: {"v":"代理"} + +data: {"v":"、"} + +data: {"v":"部分"} + +data: {"v":"安全"} + +data: {"v":"网关"} + +data: {"v":"会"} + +data: {"v":"阻断"} + +data: {"v":"Up"} + +data: {"v":"grade"} + +data: {"v":"请求"} + +data: {"v":","} + +data: {"v":"导致"} + +data: {"v":"连接"} + +data: {"v":"失败"} + +data: {"v":"。"} + +data: {"v":"在"} + +data: {"v":"复杂"} + +data: {"v":"网络"} + +data: {"v":"环境"} + +data: {"v":"或"} + +data: {"v":"to"} + +data: {"v":"B"} + +data: {"v":"交付"} + +data: {"v":"中"} + +data: {"v":","} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"具有"} + +data: {"v":"更"} + +data: {"v":"广泛的"} + +data: {"v":"兼容"} + +data: {"v":"性"} + +data: {"v":"。\n\n"} + +data: {"v":"**"} + +data: {"v":"五"} + +data: {"v":"、"} + +data: {"v":"成本"} + +data: {"v":"**"} + +data: {"v":" \n"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"开发"} + +data: {"v":"成本"} + +data: {"v":"低"} + +data: {"v":":"} + +data: {"v":"服务"} + +data: {"v":"端"} + +data: {"v":"只需"} + +data: {"v":"设置"} + +data: {"v":"`"} + +data: {"v":"Content"} + +data: {"v":"-Type"} + +data: {"v":":"} + +data: {"v":" text"} + +data: {"v":"/"} + +data: {"v":"event"} + +data: {"v":"-stream"} + +data: {"v":"`"} + +data: {"v":"并"} + +data: {"v":"持续"} + +data: {"v":"写入"} + +data: {"v":"数据"} + +data: {"v":","} + +data: {"v":"客户端"} + +data: {"v":"使用"} + +data: {"v":"浏览器"} + +data: {"v":"内置"} + +data: {"v":"的"} + +data: {"v":"Event"} + +data: {"v":"Source"} + +data: {"v":","} + +data: {"v":"无需"} + +data: {"v":"引入"} + +data: {"v":"额外"} + +data: {"v":"库"} + +data: {"v":"。"} + +data: {"v":"维护"} + +data: {"v":"也"} + +data: {"v":"简单"} + +data: {"v":","} + +data: {"v":"因为"} + +data: {"v":"问题"} + +data: {"v":"排查"} + +data: {"v":"遵循"} + +data: {"v":"HTTP"} + +data: {"v":"语义"} + +data: {"v":"。"} + +data: {"v":" \n"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"需"} + +data: {"v":"引入"} + +data: {"v":"专用"} + +data: {"v":"库"} + +data: {"v":","} + +data: {"v":"自行"} + +data: {"v":"处理"} + +data: {"v":"分"} + +data: {"v":"帧"} + +data: {"v":"、"} + +data: {"v":"心跳"} + +data: {"v":"、"} + +data: {"v":"重"} + +data: {"v":"连"} + +data: {"v":"、"} + +data: {"v":"序列"} + +data: {"v":"化"} + +data: {"v":"等"} + +data: {"v":","} + +data: {"v":"代码"} + +data: {"v":"量"} + +data: {"v":"较大"} + +data: {"v":","} + +data: {"v":"且"} + +data: {"v":"调试"} + +data: {"v":"时"} + +data: {"v":"无法"} + +data: {"v":"像"} + +data: {"v":"HTTP"} + +data: {"v":"那样"} + +data: {"v":"直接"} + +data: {"v":"查看"} + +data: {"v":"明文"} + +data: {"v":"流"} + +data: {"v":","} + +data: {"v":"维护"} + +data: {"v":"成本"} + +data: {"v":"高"} + +data: {"v":"。"} + +data: {"v":"从"} + +data: {"v":"总"} + +data: {"v":"拥有"} + +data: {"v":"成本"} + +data: {"v":"("} + +data: {"v":"T"} + +data: {"v":"CO"} + +data: {"v":")"} + +data: {"v":"看"} + +data: {"v":","} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"优势"} + +data: {"v":"明显"} + +data: {"v":"。\n\n"} + +data: {"v":"**"} + +data: {"v":"六"} + +data: {"v":"、"} + +data: {"v":"可"} + +data: {"v":"观测"} + +data: {"v":"性"} + +data: {"v":"**"} + +data: {"v":" \n"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"完美"} + +data: {"v":"融入"} + +data: {"v":"HTTP"} + +data: {"v":"生态"} + +data: {"v":":"} + +data: {"v":"所有"} + +data: {"v":"HTTP"} + +data: {"v":"中间"} + +data: {"v":"件"} + +data: {"v":"、"} + +data: {"v":"日志"} + +data: {"v":"系统"} + +data: {"v":"、"} + +data: {"v":"AP"} + +data: {"v":"M"} + +data: {"v":"工具"} + +data: {"v":"、"} + +data: {"v":"链路"} + +data: {"v":"追踪"} + +data: {"v":"均可"} + +data: {"v":"直接"} + +data: {"v":"用于"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"连接"} + +data: {"v":","} + +data: {"v":"可通过"} + +data: {"v":"请求"} + +data: {"v":"头"} + +data: {"v":"注入"} + +data: {"v":"trace"} + +data: {"v":" ID"} + +data: {"v":","} + +data: {"v":"并"} + +data: {"v":"利用"} + +data: {"v":"访问"} + +data: {"v":"日志"} + +data: {"v":"分析"} + +data: {"v":"流量"} + +data: {"v":"、"} + +data: {"v":"排查"} + +data: {"v":"错误"} + +data: {"v":"。"} + +data: {"v":" \n"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":"连接"} + +data: {"v":"升级"} + +data: {"v":"后"} + +data: {"v":"便"} + +data: {"v":"脱离"} + +data: {"v":"HTTP"} + +data: {"v":"语境"} + +data: {"v":","} + +data: {"v":"后续"} + +data: {"v":"帧"} + +data: {"v":"交换"} + +data: {"v":"需"} + +data: {"v":"应用"} + +data: {"v":"层"} + +data: {"v":"埋"} + +data: {"v":"点"} + +data: {"v":"记录"} + +data: {"v":","} + +data: {"v":"与"} + +data: {"v":"标准"} + +data: {"v":"HTTP"} + +data: {"v":"监控"} + +data: {"v":"工具的"} + +data: {"v":"集成"} + +data: {"v":"度"} + +data: {"v":"差"} + +data: {"v":","} + +data: {"v":"对"} + +data: {"v":"可"} + +data: {"v":"观测"} + +data: {"v":"性"} + +data: {"v":"平台"} + +data: {"v":"的要求"} + +data: {"v":"更高"} + +data: {"v":","} + +data: {"v":"无形"} + +data: {"v":"中"} + +data: {"v":"增加了"} + +data: {"v":"运维"} + +data: {"v":"成本"} + +data: {"v":"。\n\n"} + +data: {"v":"**"} + +data: {"v":"分层"} + +data: {"v":"架构"} + +data: {"v":"建议"} + +data: {"v":"**"} + +data: {"v":" \n"} + +data: {"v":"基于"} + +data: {"v":"上述"} + +data: {"v":"对比"} + +data: {"v":","} + +data: {"v":"推荐"} + +data: {"v":"以"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"作为"} + +data: {"v":"AI"} + +data: {"v":"推理"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"输出的"} + +data: {"v":"默认"} + +data: {"v":"协议"} + +data: {"v":","} + +data: {"v":"并"} + +data: {"v":"采用"} + +data: {"v":"以下"} + +data: {"v":"分层"} + +data: {"v":"架构"} + +data: {"v":":\n\n"} + +data: {"v":"-"} + +data: {"v":" **"} + +data: {"v":"客户端"} + +data: {"v":"层"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"浏览器"} + +data: {"v":"/"} + +data: {"v":"App"} + +data: {"v":"通过"} + +data: {"v":"Event"} + +data: {"v":"Source"} + +data: {"v":"("} + +data: {"v":"或"} + +data: {"v":"轻"} + +data: {"v":"量"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":" poly"} + +data: {"v":"fill"} + +data: {"v":")"} + +data: {"v":"建立"} + +data: {"v":"连接"} + +data: {"v":","} + +data: {"v":"接收"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"数据"} + +data: {"v":"。"} + +data: {"v":"若"} + +data: {"v":"需"} + +data: {"v":"发送"} + +data: {"v":"控制"} + +data: {"v":"指令"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"停止"} + +data: {"v":"生成"} + +data: {"v":"),"} + +data: {"v":"可通过"} + +data: {"v":"独立"} + +data: {"v":"HTTP"} + +data: {"v":" API"} + +data: {"v":"发出"} + +data: {"v":","} + +data: {"v":"形成"} + +data: {"v":"“"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"推"} + +data: {"v":"流"} + +data: {"v":" +"} + +data: {"v":" HTTP"} + +data: {"v":"指令"} + +data: {"v":"”"} + +data: {"v":"的"} + +data: {"v":"混合"} + +data: {"v":"模式"} + +data: {"v":"。\n"} + +data: {"v":"-"} + +data: {"v":" **"} + +data: {"v":"接入"} + +data: {"v":"层"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"部署"} + +data: {"v":"支持"} + +data: {"v":"长"} + +data: {"v":"连接的"} + +data: {"v":"API"} + +data: {"v":"网关"} + +data: {"v":"或"} + +data: {"v":"反向"} + +data: {"v":"代理"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"N"} + +data: {"v":"ginx"} + +data: {"v":"),"} + +data: {"v":"负责"} + +data: {"v":"SSL"} + +data: {"v":"卸载"} + +data: {"v":"、"} + +data: {"v":"限"} + +data: {"v":"流"} + +data: {"v":"、"} + +data: {"v":"认证"} + +data: {"v":"、"} + +data: {"v":"请求"} + +data: {"v":"路由"} + +data: {"v":"。"} + +data: {"v":"配置"} + +data: {"v":"禁用"} + +data: {"v":"缓冲"} + +data: {"v":"、"} + +data: {"v":"延长"} + +data: {"v":"超"} + +data: {"v":"时"} + +data: {"v":","} + +data: {"v":"并"} + +data: {"v":"透"} + +data: {"v":"传"} + +data: {"v":"必要"} + +data: {"v":"头"} + +data: {"v":"域"} + +data: {"v":"。\n"} + +data: {"v":"-"} + +data: {"v":" **"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"服务"} + +data: {"v":"层"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"核心"} + +data: {"v":"中间"} + +data: {"v":"层"} + +data: {"v":","} + +data: {"v":"管理"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"连接"} + +data: {"v":"生命周期"} + +data: {"v":"、"} + +data: {"v":"会话"} + +data: {"v":"状态"} + +data: {"v":"和"} + +data: {"v":"事件"} + +data: {"v":"缓存"} + +data: {"v":"。"} + +data: {"v":"该"} + +data: {"v":"层"} + +data: {"v":"将"} + +data: {"v":"推理"} + +data: {"v":"引擎"} + +data: {"v":"的"} + +data: {"v":"token"} + +data: {"v":"流"} + +data: {"v":"转化为"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"事件"} + +data: {"v":"并"} + +data: {"v":"广播"} + +data: {"v":";"} + +data: {"v":"同时"} + +data: {"v":"基于"} + +data: {"v":"`"} + +data: {"v":"Last"} + +data: {"v":"-"} + +data: {"v":"Event"} + +data: {"v":"-ID"} + +data: {"v":"`"} + +data: {"v":"实现"} + +data: {"v":"断"} + +data: {"v":"线"} + +data: {"v":"续"} + +data: {"v":"传"} + +data: {"v":","} + +data: {"v":"维护"} + +data: {"v":"一个"} + +data: {"v":"有限"} + +data: {"v":"大小的"} + +data: {"v":"轮"} + +data: {"v":"转"} + +data: {"v":"事件"} + +data: {"v":"缓冲区"} + +data: {"v":"。\n"} + +data: {"v":"-"} + +data: {"v":" **"} + +data: {"v":"推理"} + +data: {"v":"引擎"} + +data: {"v":"层"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"封装"} + +data: {"v":"AI"} + +data: {"v":"模型"} + +data: {"v":","} + +data: {"v":"提供"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"回调"} + +data: {"v":"或"} + +data: {"v":"g"} + +data: {"v":"R"} + +data: {"v":"PC"} + +data: {"v":"流"} + +data: {"v":"接口"} + +data: {"v":","} + +data: {"v":"向"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"服务"} + +data: {"v":"层"} + +data: {"v":"逐"} + +data: {"v":"token"} + +data: {"v":"输出"} + +data: {"v":"。"} + +data: {"v":"该"} + +data: {"v":"层"} + +data: {"v":"可"} + +data: {"v":"无"} + +data: {"v":"状态"} + +data: {"v":"或"} + +data: {"v":"依赖"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"服务"} + +data: {"v":"层"} + +data: {"v":"传递"} + +data: {"v":"的"} + +data: {"v":"上下文"} + +data: {"v":"。\n"} + +data: {"v":"-"} + +data: {"v":" **"} + +data: {"v":"可"} + +data: {"v":"观测"} + +data: {"v":"性"} + +data: {"v":"层"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"贯穿"} + +data: {"v":"各"} + +data: {"v":"层"} + +data: {"v":","} + +data: {"v":"统一"} + +data: {"v":"采集"} + +data: {"v":"日志"} + +data: {"v":"、"} + +data: {"v":"指标"} + +data: {"v":"和"} + +data: {"v":"追踪"} + +data: {"v":"数据"} + +data: {"v":"。"} + +data: {"v":"利用"} + +data: {"v":"接入"} + +data: {"v":"层的"} + +data: {"v":"访问"} + +data: {"v":"日志"} + +data: {"v":"和"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"服务"} + +data: {"v":"层的"} + +data: {"v":"业务"} + +data: {"v":"日志"} + +data: {"v":","} + +data: {"v":"快速"} + +data: {"v":"定位"} + +data: {"v":"延迟"} + +data: {"v":"或"} + +data: {"v":"断开"} + +data: {"v":"问题"} + +data: {"v":"。\n\n"} + +data: {"v":"若"} + +data: {"v":"场景"} + +data: {"v":"中"} + +data: {"v":"客户端"} + +data: {"v":"与"} + +data: {"v":"服务器"} + +data: {"v":"有"} + +data: {"v":"高频"} + +data: {"v":"双向"} + +data: {"v":"交互"} + +data: {"v":"需求"} + +data: {"v":"("} + +data: {"v":"如"} + +data: {"v":"实时"} + +data: {"v":"对话"} + +data: {"v":"调整"} + +data: {"v":"参数"} + +data: {"v":"),"} + +data: {"v":"可"} + +data: {"v":"局部"} + +data: {"v":"引入"} + +data: {"v":"Web"} + +data: {"v":"Socket"} + +data: {"v":","} + +data: {"v":"但仍"} + +data: {"v":"建议"} + +data: {"v":"将"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"输出"} + +data: {"v":"统一"} + +data: {"v":"收敛"} + +data: {"v":"到"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":","} + +data: {"v":"以"} + +data: {"v":"保留"} + +data: {"v":"可靠"} + +data: {"v":"性和"} + +data: {"v":"可"} + +data: {"v":"观测"} + +data: {"v":"性"} + +data: {"v":"优势"} + +data: {"v":"。"} + +data: {"v":"总体"} + +data: {"v":"而言"} + +data: {"v":","} + +data: {"v":"优先"} + +data: {"v":"选择"} + +data: {"v":"SS"} + +data: {"v":"E"} + +data: {"v":"能够"} + +data: {"v":"以"} + +data: {"v":"更"} + +data: {"v":"低的"} + +data: {"v":"工程量"} + +data: {"v":"实现"} + +data: {"v":"稳定"} + +data: {"v":"、"} + +data: {"v":"可"} + +data: {"v":"维护"} + +data: {"v":"的"} + +data: {"v":"流"} + +data: {"v":"式"} + +data: {"v":"推理"} + +data: {"v":"系统"} + +data: {"v":"。"} + +data: {"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":1357},{"p":"quasi_status","v":"FINISHED"}]} + +data: {"p":"response/status","o":"SET","v":"FINISHED"} + +event: update_session +data: {"updated_at":1777485165.4124339} + +event: title +data: {"content":"SSE与WebSocket流式输出比较"} + +event: close +data: {"click_behavior":"none","auto_resume":false} + diff --git a/tests/raw_stream_samples/manifest.json b/tests/raw_stream_samples/manifest.json index 221d04f..688ffaf 100644 --- a/tests/raw_stream_samples/manifest.json +++ b/tests/raw_stream_samples/manifest.json @@ -1,8 +1,8 @@ { "version": 1, "default_samples": [ - "guangzhou-weather-reasoner-search-20260404", - "content-filter-trigger-20260405-jwt3" + "longtext-deepseek-v4-flash-20260429", + "longtext-deepseek-v4-pro-20260429" ], - "notes": "Canonical raw stream samples used by the default replay simulator." + "notes": "Canonical long-text upstream raw stream samples refreshed on 2026-04-29." }