token-streaming.md 7.3 KB

正文真实增量流式输出(F073)

POST /api/chat 保留阶段、心跳、公司确认、来源、卡片及最终结果事件。新增正文流事件,后端以 stream=true 读取模型上游 SSE,收到公开正文片段就推送,无需等完整 JSON 或整个节点结束。不是完整回答后的逐字播放。上游一个片段可能包含一个或多个 token,不保证逐汉字、逐词或严格 tokenizer 边界。

事件契约

卡片先于正文(F079,2026-09-20)

有入选政策时,先完成筛选、排序、扶持短文案及条件整理,再发送完整 sourceitem,然后开始综合讲解的 answer_start / answer_delta。普通政策总结和政策+其他知识的联合讲解都遵循此顺序。卡片不逐字段流式生成;同题QA排除的政策不会提前露出。

前端收到 item 即可展示卡片(默认前6张、其余展开),卡片下方接续流式正文;不要等 summaryresult 才显示卡片。节点结束不会重复发送已发来源和卡片,result 仍含完整快照,用于校准而非重复追加。卡片及来源去重仅限当前请求,不跨会话。

讲解降级或草稿撤回时,已校验卡片保留,正文按原替换/撤回协议处理;请求发生终止错误或断线时,仍须标记本轮未完整完成。无卡片知识问答及闲聊沿用原流程。检索、逐条分析及卡片短文案仍需等待,本次不改变模型轮次或筛选依据。后端需重启,外部前端/代理尚未联调。

沿用外层 {thread_id, request_id, data}。以下字段位于 data,以一次请求的 request_idstream_id 定位正文块。

事件 字段及含义 展示动作
answer_start stream_id 本次正文生成ID;targetsummaryanswersequence=0provisional=true 新建或清空对应的生成中正文块
answer_delta 同一ID/target;sequence 从1递增;text 为新增字符串 按顺序追加text,立即渲染
answer_end 同一ID/target;sequence为最后片段序号;text 为完整最终正文;operation=replacestatus=completed/fallbackprovisional=false 整块替换,不要追加
answer_abort 同一ID/target;sequence为最后片段序号;operation=discardreasongeneration_failed/regenerating/not_used/not_finalized 撤去这次临时正文,等待后续正常回答或失败提示

answer_end 后仍保留原 answersummary 完整事件,并新增可选 stream_id 与该正文关联;这些完整事件用于兼容旧客户端及提供来源等字段,不是另一份正文。最终 result 结构不变,不保存流式ID;断线恢复、历史渲染以完整结果为准。不要把 answer_delta 当成完整 answer

event: answer_start
data: {"thread_id":"demo","request_id":"req","data":{"stream_id":"s1","target":"summary","sequence":0,"provisional":true}}

event: answer_delta
data: {"thread_id":"demo","request_id":"req","data":{"stream_id":"s1","target":"summary","sequence":1,"text":"我们青浦"}}

event: answer_delta
data: {"thread_id":"demo","request_id":"req","data":{"stream_id":"s1","target":"summary","sequence":2,"text":"的合成服务说明。"}}

event: answer_end
data: {"thread_id":"demo","request_id":"req","data":{"stream_id":"s1","target":"summary","sequence":2,"text":"我们青浦的合成服务说明。","operation":"replace","status":"completed","provisional":false}}

前端使用 POST fetch 的 response.body 持续读取;不要先 await response.text() 再解析。使用流式 TextDecoder 处理跨网络包UTF-8,将未组成完整SSE事件的字符保留到下个包;一个网络包不等于一个事件。完成一个空行分隔事件后,解析外层JSON并按事件名处理。

同一个请求的事件分发建议:

  1. answer_start 建立临时正文,记录ID、target及最后序号0。
  2. answer_delta 只接受当前ID,忽略已消费序号,按顺序拼接text;序号缺口标记正文不完整,等最终替换。Markdown可按每帧刷新,避免每个片段重排整页;不要把模型正文当原始HTML插入。
  3. answer_end 无论状态是completed还是fallback,均用text覆盖已有内容并解除生成中状态。最终校验或程序补充可能改变草稿,不能只停止光标而保留草稿。
  4. answer_abort 丢弃当前ID临时内容。后续原answer/summary照常展示;没有流ID的完整事件也要支持。
  5. 原answer/summary若有已见ID,只更新同一块的最终文本和元数据;不要新增第二份回答。result 校准最终展示,沿用原answer/summary去重规则;error、连接异常或没有正常终止的EOF须撤去尚未完成草稿,不能显示为成功答复。

新客户端兼容无新事件的旧后端;旧客户端忽略新事件仍能拿到完整回答,但没有增量展示效果。外部前端不在本仓库,需按本契约接入。

输出范围与最终校验

  • 政策总结及知识综合:只提取顶层 text;闲聊:已确认 conversation_kind=smalltalk 后才提取 direct_response。识别提示要求类型先输出;若模型未遵循字段顺序,保守保持完整答复,不提前猜类型。
  • 查询规划、候选证据分析、分层压缩、公司资料、JSON字段、source_ids及模型reasoning均不作为正文片段输出。业务画像、来源和卡片继续完整返回。
  • JSON转义、跨片段Unicode和代理对先解码再展示。金融内部占位标记不展示;完整校验后绑定真实链接,随最终替换出现。
  • 临时正文尚未通过完整JSON和来源引用校验。校验失败可能替换为明确的降级答复;上游断流、请求取消等撤回草稿。原证据约束、输出预算、并发信号量和请求作用域保持,不增加生成轮次。
  • 同题QA原文、无需模型生成的短答案、固定提示和公司候选等仍一次返回,不模拟逐字输出。检索及证据分析耗时仍存在,期间继续阶段和心跳;本功能缩短最终正文生成时的展示等待,不声称消除整条链路首字等待。

运行与验证

后端重启加载代码。已有8事件有界队列提供背压,断连/超时通知模型读取循环停止;阻塞的网络读仍可能等至读取超时,不提前释放仍运行的工作线程和会话锁。生产反向代理需关闭SSE缓冲;如直连已逐段而代理整段返回,应排查代理缓冲及压缩。未部署或验证外部代理/前端。

本轮210项问答、53项API合成测试通过,包括真实JsonModel/GraphRuntime到Service SSE消费者的完成前增量检查、跨片段转义、错误撤回、最终替换、并发隔离,以及既有背压与断连回归。

纯合成真实模型验证:.venv\Scripts\python.exe -m src.step3_question_answer.token_stream_check,统计记录 output/checks/token-stream.json,只保存耗时/片段数/断言。2026-09-20政策、知识、闲聊三路径首片段分别约0.58/0.67/0.74秒,模型完成约0.97/1.03/1.65秒;这是直接模型调用的合成样例,不含真实检索、代理或用户网络耗时,不能作为生产延迟承诺。

上游协议参照DeepSeek Chat Completion的delta.content、finish_reason与[DONE],不转发推理字段。