# POST /api/chat 接口文档 **看不懂英文key或value时,先查 [字段与取值中文对照表](api-chat-fields-zh.md)**:逐层说明字段中文名称、状态/类型/原因的含义,以及前端该展示哪些内容。 适用版本:2026-09-14,当前F026–F028实现。服务名称:青浦区营商助手。本文供前端与接口调用方对接,重点说明实际返回格式;启动与部署配置见 [第四步服务说明](step4-api.md)。 **一次请求返回一串SSE事件,不是一个普通JSON响应。** 所有事件使用相同外层结构;普通聊天返回文字,政策与具体需求还会返回来源、卡片、综合说明。本文政策和企业名称均为合成示例,不代表存在对应政策或用户具备资格。标注“片段”的示例仅展示部分字段。 ## 1. 接口与请求 | 项目 | 说明 | | --- | --- | | 方法 / 路径 | `POST /api/chat` | | 地址 | `http://:/api/chat`;客户端使用实际服务IP/域名,不使用监听地址0.0.0.0 | | 请求类型 | `Content-Type: application/json` | | 建议响应类型 | `Accept: text/event-stream`(当前不强制校验) | | 成功响应 | HTTP 200,`Content-Type: text/event-stream; charset=utf-8` | | 默认配置 | 本机127.0.0.1:8000,监听地址和端口由环境配置控制 | ```json { "thread_id": "window-20260914-001", "question": "我想开一家小店,但启动资金不够,有什么办法?" } ``` | 参数 | 类型 | 必填 | 约束与含义 | | --- | --- | --- | --- | | thread_id | string | 是 | 窗口标识,1–128字符,仅ASCII字母、数字、`_`、`-` | | question | string | 是 | 本轮输入,不能为空或全空白,最多8000字符 | 只接受这两个字段,多余字段也返回422。请求体最多65536字节,读取请求体超时15秒。新窗口可生成 `crypto.randomUUID()`;同一窗口复用ID。未存在或Redis已过期的ID开始新会话。当前ID不是用户鉴权凭证。 ## 2. 返回的两层结构 网络上的一条事件如下,**最后一个空行是事件结束标记**: ```text event: progress data: {"thread_id":"window-20260914-001","request_id":"a78b17a3b38241fa83c8ee91f40ec608","data":{"stage":"retrieve","status":"running","message":"正在检索相关政策和配套服务……"}} ``` `event:` 指定事件名称;`data:` 后面才是可用 `JSON.parse()` 解析的JSON: ```json { "thread_id": "window-20260914-001", "request_id": "a78b17a3b38241fa83c8ee91f40ec608", "data": { "stage": "retrieve", "status": "running", "message": "正在检索相关政策和配套服务……" } } ``` | 外层字段 | 类型 | 说明 | | --- | --- | --- | | thread_id | string | 本轮所属窗口,等于请求值 | | request_id | string | 服务端为本次HTTP请求生成的32位十六进制ID;同一流内保持一致,下一次请求会改变 | | data | object | 事件业务内容,由event名称决定 | 事件名称不在JSON对象中,没有 `type`、`event` 或 `code=200` 这类统一业务字段。除完整网络示例外,下文各事件的JSON示例都只展示这个内层 **data对象**。 一个网络数据块可能包含半条事件或多条事件,中文UTF-8字符也可能跨块;不可对每次 `reader.read()` 的结果直接执行 `JSON.parse()`。 ## 3. 事件总览与顺序 | event | 主要data字段 | 前端用途 | | --- | --- | --- | | accepted | message | 立即显示“已收到问题” | | progress | stage、status、message | 更新当前处理阶段 | | heartbeat | status、message、elapsed_seconds | 等待期间更新已耗时 | | answer | text | 普通对话正文,或推荐的开场概述 | | source | id、title、full_record等 | 保存完整来源,供引用详情查看 | | item | source_id、card、answer、conditions等 | 渲染单张政策/服务推荐卡片 | | summary | text、source_ids、status、notices | 显示末尾综合说明 | | interrupt | kind、question、input_help等 | 提示用户补充公司信息或确认候选 | | result | response、recommendation、errors、company_info、cipa | 当前轮次的完整最终快照 | | done | status | 本次流正常结束或等待输入 | | error | code,可选message | 本次流异常结束 | 正常顺序(`…`仅表示零个或多个事件,不是协议字符): ```text 普通对话:accepted → progress… → answer → result → done 政策/需求:accepted → progress… → answer → source… → item… → summary → result → done 公司补问:accepted → progress… → interrupt → result → done(needs_input) 流中失败:accepted → 已产生的事件… → error ``` 耗时期间可穿插heartbeat,失败可能发生在任一阶段。不保证有source/item;不保证每次请求都经过所有progress阶段。`error`之后不保证有result或done。 这是**真实阶段与已生成结果的事件流**,不是模型逐token输出。普通回复在LLM分类与生成完成后发送;推荐须整体生成并校验后,再依次发送source/item/summary。此时不要把逐条item到达理解为后台刚分析完对应条目。 ## 4. 处理中间状态 ### accepted ```json {"message":"已收到您的问题,正在处理……"} ``` 表示已接纳请求,不表示模型调用或业务处理成功。 ### progress ```json {"stage":"retrieve","status":"running","message":"正在检索相关政策和配套服务……"} ``` | status | 含义 | | --- | --- | | running | 阶段开始,先通知再执行耗时节点 | | completed | 该阶段执行结束;不等于政策符合或所有依赖成功 | | waiting_input | 需要用户输入,随后有interrupt | | failed | 节点抛出异常,随后终止错误;已被业务捕获的失败仍可能表现为completed并在结果中说明 | | stage | 阶段 | | --- | --- | | session | 读取会话 | | prepare | 整理问题 | | analyze | LLM需求识别;普通对话回复也在这次调用生成 | | direct | 输出普通对话答复 | | clarify | 准备公司信息补问 | | search | 查询公司候选 | | confirm | 整理公司候选供用户确认 | | basic | 获取已确认公司的工商信息 | | honors | 查询荣誉与资质 | | handoff | 整理需求与已有信息 | | plan | 规划政策检索方向 | | retrieve | 检索相关政策与服务 | | recommend | 原文分析、条件比较和回答整理 | 前端优先直接展示message。相同阶段可能因恢复或重新查询再次出现,不能将stage当作全会话唯一事件ID。不提供百分比或预计剩余时间。 ### heartbeat ```json {"status":"processing","message":"问题仍在处理中,请稍候……","elapsed_seconds":20} ``` 无可发送结果且任务仍执行时按配置间隔发送,默认约10秒;不是严格定时器。elapsed_seconds是本次请求累计耗时的整数秒,不是当前节点耗时,也不是剩余时间。收到done/error后停止等待动画。 ## 5. 普通对话:文字结果 身份和能力咨询、普通信息、问候、闲聊等由LLM按原话生成。示例问题:“今天工作好累”。 `answer.data`: ```json {"text":"辛苦了,先歇一歇,喝点水,给自己留一点放松的时间。"} ``` `result.data`: ```json { "response": "辛苦了,先歇一歇,喝点水,给自己留一点放松的时间。", "recommendation": {}, "errors": [] } ``` ### cipa(`result.data.cipa`) **布尔值**,表示本轮是否需要展示**企业微信二维码**(2026-09-20 后端新增): | 取值 | 含义 | 前端行为 | |---|---|---| | `true` | 问的是 **CIPA / 海外服务平台 / 出海服务** | 在**参考资料之前**追加一张企业微信二维码(`` 块,点击可放大) | | `false` / 缺失 | 其他情况 | 不展示 | | `true` + **本轮有 `interrupt`** | 补问轮(等用户补充公司信息 / 确认候选) | **不展示**:那一轮界面上是补问卡片、还没有正文回答(2026-09-20 实测踩到) | - 前端按 **`=== true` 严格判定**,非布尔值一律当 false - ✅ 该字段已于 2026-09-20 部署到开发环境(浏览器实测已看到二维码渲染) - ⚠️ 后端偶发 500(当日实测一次,请求返回 0 字节),前端按流中断处理,不重试 ### company_info(`result.data.company_info`) 公司类问题会额外带回企业资料,**结构是 `{company, profile, honors}` 三段**(2026-09-18 实测, 取自一次真实请求;只列关键字段,实际字段更多): ```json { "company": { "KeyNo": "aaj9myb…", "Name": "上海某某科技有限公司", "CreditCode": "91310118MA1JLRL16G", "StartDate": "2017-03-16", "OperName": "黄朝梁", "Status": "存续", "No": "310118003427665", "Address": "上海市青浦区…" }, "profile": { "data": { "KeyNo": "…", "Name": "…", "CreditCode": "…", "OperName": "…", "Status": "存续(在营、开业、在册)", "RegistCapi": "1000万元", "Address": "…", "Scope": "一般项目:…", "EconKind": "…", "StartDate": "2017-03-16 00:00:00", "Area": {"Province":"上海市","City":"上海市","County":"青浦区"}, "OriginalName": [{"Name":"曾用名","ChangeDate":"2024-07-22"}], "…": "还有 30 余个工商字段" }, "source": { "provider": "企查查", "api_code": "410", "fetched_at": "2026-09-18T06:30:32+00:00" } }, "honors": { "records": [], "sources": [{"provider":"企查查","api_code":"1001"}], "complete": true, "error": null } } ``` 用法与注意事项: - **取值顺序**:企业名/信用代码/法人取 `profile.data.Name/CreditCode/OperName`, 缺失才回退 `company` 同名字段;都可能为 `null`(无企业资料时) - 该字段**不是每轮都有**:只有识别到公司的对话才带;`result` 缺失该键就是没有 - 前端**不解析、不渲染**它,只把它原样交给企业信息同步(见 [`company-classification.md`](company-classification.md))—— 分类接口的请求体就是它,**不要包外层、不要塞 thread_id** - ⚠️ 实测:分类接口对**精简/合成的请求体一律 422**,必须给完整的 `{company, profile, honors}` 结构(2026-09-18:文档里那个合成片段也被拒,换成真实抓下来的整份才 200) `done.data`: ```json {"status":"completed"} ``` answer.text和result.response是同一回复,后者不要再追加一次。没有item/source/summary。普通对话的内部分类目前不单独暴露在result中,前端无需区分service_intro、smalltalk或general_info才能展示。 模型调用或生成格式失败时,可能仍收到固定兜底文字、result及done=completed。应检查result.errors,例如 `intent_analysis_failed` 或 `direct_response_unavailable`,不能只凭done判断内容生成成功。 ## 6. 政策与具体需求:结构化结果 两类共用推荐数据结构,区别如下: | 内容 | 了解政策 | 具体需求 | | --- | --- | --- | | 示例 | “有哪些创业扶持政策?” | “我想开店但资金不足” | | recommendation.conversation_kind | policy_overview | specific_need | | audience | general | individual或enterprise | | 卡片顺序 | 保留原检索顺序 | 按需求与主体依据优先级排序 | | eligibility | not_evaluated | 当前入选条目通常为待确认 | | conditions | 通常为空,不做用户资格判断 | 地区、主体、行业、时间等条件核验 | | follow_up_questions | 空数组 | 需要确认的关键条件,最多5条 | | 公司确认 | 不需要 | 仅在当前需求需要企业事实时触发 | **answer.text只是概述,卡片正文在item.answer及item.card,末尾结论在summary.text。** 只显示answer会丢失主要业务内容。 ### 6.1 result.data完整外形 以下为“成功检索但没有相关依据”的合成示例;有推荐时items/sources用后续小节的对象填充: ```json { "response": "当前知识库未找到足够相关的原文依据,不能据此认定不存在相关政策。请补充具体需求。", "recommendation": { "overview": "当前知识库未找到足够相关的原文依据,不能据此认定不存在相关政策。请补充具体需求。", "conversation_kind": "policy_overview", "audience": "general", "items": [], "sources": [], "follow_up_questions": [], "summary": { "text": "本轮没有筛选出可展示的具体推荐。可补充希望解决的事项,例如融资、设备更新或人才招聘,以便进一步查找。", "source_ids": [], "status": "no_recommendations", "notices": [] }, "excluded_items": [], "presentation": {"order":["items","summary"],"sorted_by":"subject_policy_evidence"}, "retrieval_analysis_complete": true, "complete": true, "company_context": {"profile_verified":false,"honors_complete":null,"errors":[]}, "analysis_errors": [], "retrieval": { "queries": ["合成主题查询"], "failed_databases": [], "failed_queries": [], "content_failures": [], "complete": true, "snapshot": "0000000000000000000000000000000000000000000000000000000000000000", "searched_types": ["policy","finance","qa","article","service_guide","service_catalog"], "candidate_limit": 12 }, "search_plan": {"needs":[],"queries":[],"error":null} }, "errors": [] } ``` 这里的snapshot为占位值;searched_types实际为当前检索类型列表,不应据本例写死。retrieval为后端诊断数据,降级时部分键可能不存在。 | 字段(相对recommendation) | 类型 | 说明 | | --- | --- | --- | | overview | string | 与result.response、answer.text一致的概述 | | items | array | 已筛选并排序的展示条目,不额外限制6条;当前检索仍有候选预算 | | sources | array | 完整来源,不一定与items一一对应;可能含仅供参考或分析未完成的来源 | | summary | object | 综合说明;独立summary事件与这里相同 | | follow_up_questions | string[] | 资格待确认问题列表,不是LangGraph中断 | | excluded_items | array | 未入选条目的source_id与reason,适合详情或诊断 | | presentation | object | 建议先显示items再summary;实际顺序以items数组/priority.rank为准 | | retrieval_analysis_complete | boolean | 本轮有限候选检索和逐项分析是否完整;不表示总体召回充分 | | complete | boolean | 在上项基础上考虑必要企业信息完整性等;不表示获批,且不包含所有降级状态 | | company_context | object | profile_verified、honors_complete和errors;不是完整企业画像 | | analysis_errors | array | 各候选分析错误,通常为record_id/code | | retrieval | object | 查询、失败库/查询/内容、快照等诊断;无候选全文数组 | | search_plan | object | needs、queries、error;needs每项有need/evidence/query,均为检索假设 | result.errors只包含会话/图层错误,不能替代recommendation.analysis_errors、retrieval、summary.status/error等字段。 ### 6.2 item:一张卡片及其证据 下面是政策介绍模式的合成item.data,展示完整顶层字段: ```json { "source_id": "k_000000000000000000000001", "title": "合成创业服务示例", "card": { "kind": "service", "name": {"text":"合成创业服务示例","citations":[{"path":"/title","quote":"合成创业服务示例"}]}, "department": null, "support_objects": {"text":"拟创业人员","citations":[{"path":"/raw_record/content","quote":"面向拟创业人员"}]}, "support_methods_standards": {"text":"提供创业咨询","citations":[{"path":"/raw_record/content","quote":"提供创业咨询"}]}, "application_conditions": null, "match_reason": { "text": "服务内容与咨询的创业主题相关。", "citations": [{"path":"/raw_record/content","quote":"提供创业咨询"}], "subject_policy_comparisons": [] } }, "match_basis": [], "match_reason": "服务内容与咨询的创业主题相关。", "answer": "这项服务面向拟创业人员,提供创业咨询。", "citations": [{"path":"/raw_record/content","quote":"面向拟创业人员,提供创业咨询。"}], "conditions": [], "eligibility": "not_evaluated", "recommendation_status": "相关政策与服务介绍", "priority": {"rank":1,"basis":"原检索顺序"} } ``` | card字段 | 展示内容 | | --- | --- | | kind | policy / financial_product / service | | name | 完整政策、产品或服务名称,读取name.text | | department | 主管/实施部门或金融提供机构 | | support_objects | 支持对象 | | support_methods_standards | 支持方式及原文明示的标准 | | application_conditions | 原文申报条件,不等于用户符合条件 | | match_reason | 相关/匹配理由及主体—政策比较 | name及四个业务字段采用 `{text, citations}` 对象,不是字符串;后四项可能为null,可显示“原文未明确”或隐藏。当前入选items要求有有效card,前端仍应容错缺失字段。item.title是来源标题,展示正式名称优先使用card.name.text。 item.match_reason是字符串;card.match_reason是对象,含text、citations、subject_policy_comparisons。item.citations与card各字段citations中的path/quote指向该item.source_id对应的完整来源。path是记录内字段定位,不是HTTP地址。 priority.rank从1开始且数值越小越靠前;它不是分数或获批概率。不满足、明确过期、主体冲突和不适合推荐的条目会被筛出items,可查看excluded_items和sources。reason当前包括expired、ineligible、reference_only、subject_conflict、insufficient_personal_match、duplicate_name。 ### 6.3 具体需求的条件与匹配依据 以下为具体需求item的字段片段(并非完整item),展示数组元素结构: ```json { "eligibility": "待确认", "recommendation_status": "相关线索,资格待核验", "conditions": [ { "dimension": "主体", "status": "待确认", "requirement": "面向拟创业人员", "quote": "面向拟创业人员", "user_evidence": "我想开一家小店", "question": "请确认目前是否处于筹备创业阶段。" } ], "match_basis": [ { "dimension": "需求", "status": "supports", "fact_path": "/question", "fact_quote": "我想开一家小店", "policy_path": "/raw_record/content", "policy_quote": "面向拟创业人员,提供创业咨询。", "explanation": "创业咨询与用户开店准备需求相关。", "fact_origin": "user", "provenance": {"type":"user_statement"} } ] } ``` conditions维度为地区/主体/行业/时间/其他条件,状态契约为满足/不满足/待确认;当前实现对模型“满足”会保守改为“待确认”,不自动批准资格。quote、user_evidence、question可能为空字符串。 match_basis维度为需求/地区/行业/资质/主体/时间/其他,状态为supports/conflicts/unknown;fact_origin为user/company/honor,provenance按来源可能有api_code、fetched_at、page_index。supports仅表示有关联依据,不表示全部条件满足;不要与conditions的中文状态混用。 **follow_up_questions不等于interrupt。** 前者是回答内容,通常仍done=completed;目前再输入答案会开始一个新问题,并未实现基于旧卡片的增量资格重算。需要补充时前端可让用户同时带上原问题和新条件。 ### 6.4 source:完整来源 source事件及recommendation.sources中的同一对象结构如下,full_record在此只展示部分字段: ```json { "id": "k_000000000000000000000001", "title": "合成创业服务示例", "type": "policy", "source": null, "full_record": { "id": "k_000000000000000000000001", "title": "合成创业服务示例", "raw_record": {"content":"面向拟创业人员,提供创业咨询。"} }, "snapshot": "0000000000000000000000000000000000000000000000000000000000000000", "analysis_complete": true, "detail_anchor": "source-k_000000000000000000000001" } ``` 实际full_record保留入库的完整原始字段,各知识类型结构不同,不能写死只有raw_record.content。source是原始来源对象或null;实际URL、文件等定位信息应按该对象展示,无URL时不要自行拼接地址。 source.id与item.source_id关联。detail_anchor是建议使用的页面锚点,不是后端详情接口,`/api/chat`也不会生成CLI的本地HTML报告。前端可以把full_record放入可展开来源详情,并按path/quote显示引文。snapshot为知识快照标识,不代表政策生效日期。 ### 6.5 summary:综合说明 ```json { "text": "本轮服务可供筹备创业时进一步了解,具体办理条件可查看原文。", "source_ids": ["k_000000000000000000000001"], "status": "generated", "notices": [] } ``` status为generated(模型生成)、fallback(生成失败使用备用说明)或no_recommendations(没有入选推荐)。fallback时可能有 `error: "summary_generation_failed"`;notices包含本轮不完整或同名来源处理等提示。summary.source_ids可用于跳转来源。**summary降级并不一定使recommendation.complete=false,需独立检查。** ## 7. 公司补问与继续请求 ### company_need interrupt.data示例: ```json { "kind": "company_need", "question": "本次是否需要结合具体公司情况?如需要,请补充公司名称或信用代码。", "schema": {"needs_company":"boolean","company_hint":"string"}, "input_help": "输入公司名称或信用代码;不需要则输入“不需要”。" } ``` 随后仍有result和 `done.data={"status":"needs_input"}`,result.response可能为空。前端应展示interrupt.question,不能把空response当作失败。 下一次POST仍只传两个字段: ```json {"thread_id":"window-20260914-001","question":"合成示例有限公司"} ``` 无需公司信息则question填“不需要”“无需公司信息”或 `/skip`。**schema是内部补问说明,不要将needs_company/company_hint作为额外HTTP参数提交。** ### company_selection 以下interrupt.data为部分字段示例: ```json { "kind": "company_selection", "question": "请选择您所指的公司;也可以补充名称重新检索或取消。", "result": { "candidates": [ {"KeyNo":"synthetic-company-key","Name":"合成示例有限公司","CreditCode":null,"Address":null,"Status":null} ], "may_have_more": false }, "page": 1, "input_help": "输入候选序号确认,或输入新关键词;支持/next、/retry、/cancel。" } ``` 实际还包含actions等信息;候选可能有StartDate、OperName、No,缺失值为null。result.source可携带来源与获取时间,查询失败时result.error为错误码且candidates可能为空。空候选与查询失败需分别展示。 | 用户操作 | 下一次question | | --- | --- | | 选择显示的第1家公司 | `"1"`,从1开始;不要重排候选后仍按原序号提交 | | 换关键词 | 新名称或代码 | | 下一页 | `/next`,仅may_have_more=true有效 | | 查询失败重试 | `/retry` | | 取消公司查询 | `/cancel`,继续以现有信息给出一般线索,不是取消HTTP | 不能直接把actions或KeyNo对象放入question;当前question必须是字符串,API由序号映射到原候选。存在补问时下一次输入用于回答补问,不能同时作为新咨询;要另问可换thread_id。 ## 8. 三种“失败/没有结果”不要混淆 ### 8.1 流开始前:HTTP错误 ```json {"error":"thread_busy"} ``` 这不是SSE,也不带统一thread_id/request_id封装。 | HTTP状态 | error | 说明 | | --- | --- | --- | | 415 | application_json_required | Content-Type不正确 | | 422 | invalid_request | JSON或两个字段不符合约束 | | 413 | request_too_large | 超过64KiB | | 408 | request_timeout | 请求体读取超时 | | 409 | thread_busy | 同窗口的上一请求还在执行 | | 429 | server_busy | 问答并发容量已满或正在关闭 | | 503 | service_unavailable | 无法提交后台任务 | 应用的409/429/503分支附 `Retry-After: 2`,只是建议重试间隔,不保证2秒后完成。Uvicorn、代理或网关也可能返回503/其它错误及纯文本,前端不能假设每个非200响应都有JSON。 ### 8.2 流开始后:error事件 ```text event: error data: {"thread_id":"window-20260914-001","request_id":"a78b17a3b38241fa83c8ee91f40ec608","data":{"code":"stream_timeout"}} ``` | code | 说明 | | --- | --- | | processing_failed | 本轮处理抛错,有通用message;不能据此认定没有相关政策 | | stream_timeout | 超过流持续时长上限,可能只有code,没有message | | unfinished_turn | 上轮有未完成的非补问节点;同ID输入/retry恢复,或换ID提出新问题 | 此时HTTP可能仍为200。error是终止事件,之后不保证有done。未收到done/error就断开,前端显示连接中断,不能假装回答已完成;不支持Last-Event-ID重放或按事件断点续传。 ### 8.3 正常结束,但业务降级或没有推荐 - `items=[]`、`complete=true`:可能没有对应依据,也可能全部条目被筛除;结合overview、sources和excluded_items解释,不说“政策不存在”。 - `complete=false`或analysis_errors非空:存在检索/分析/必要企业信息等缺口,不是正常无结果。 - 普通对话result.errors非空:可能是LLM失败后的兜底答复。 - summary.status=fallback:综合说明降级,卡片仍可使用已返回的证据。 `done.status=completed`只表示本轮流程结束,不能代替这些业务判断。满足条款、推荐排序、完整检索均不等于获批。 ## 9. 前端渲染与解析示例 可将progress/heartbeat放在独立状态栏;answer为正文或开场,item为卡片,summary为结尾。将source按id缓存用于引用详情。result是最终快照:可以用它替换之前拼装的数据,**不要把它再次追加成第二份回答/卡片**。 正文可能包含换行或Markdown,但没有单独format字段;纯文本安全展示即可,使用Markdown渲染时关闭或清理原始HTML。字段null不应显示成“null”,空数组不表示错误。不要将thread_id当作所有请求的唯一ID,回调时用request_id区分旧流与新流。 原生EventSource不能发送此JSON POST,可使用以下fetch解析器。它处理网络分块、UTF-8跨块、LF/CRLF分隔及非JSON HTTP错误;回调接收事件名和完整外层对象: ```javascript async function chat(threadId, question, onEvent, signal) { const response = await fetch('/api/chat', { method: 'POST', headers: {'Content-Type': 'application/json', 'Accept': 'text/event-stream'}, body: JSON.stringify({thread_id: threadId, question}), signal }); if (!response.ok) { let code = `http_${response.status}`; try { code = (await response.json()).error || code; } catch {} throw new Error(code); } if (!response.headers.get('content-type')?.includes('text/event-stream')) { throw new Error('unexpected_content_type'); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = '', terminal = false; try { while (true) { const {value, done} = await reader.read(); buffer += done ? decoder.decode() : decoder.decode(value, {stream: true}); let boundary; while ((boundary = /\r?\n\r?\n/.exec(buffer)) !== null) { const block = buffer.slice(0, boundary.index); buffer = buffer.slice(boundary.index + boundary[0].length); const lines = block.split(/\r?\n/); const event = lines.find(line => line.startsWith('event:'))?.slice(6).trim(); const data = lines.filter(line => line.startsWith('data:')) .map(line => line.slice(5).replace(/^ /, '')).join('\n'); if (!event || !data) continue; const payload = JSON.parse(data); onEvent(event, payload); if (event === 'done' || event === 'error') terminal = true; } if (done) break; } if (!terminal) throw new Error('stream_disconnected'); } finally { try { await reader.cancel(); } finally { reader.releaseLock(); } } } ``` onEvent须处理error事件;函数正常解析到error不自动抛出服务错误。AbortController可以中止前端连接,但后台已发出的同步调用不能强制取消,会话锁保留到执行线程真正结束,立即重发可能409。新问题不要无条件自动重试,以免重复生成或外部调用。 ## 10. 当前接入限制 当前单进程运行,同会话排他;默认同时16个问答,超额拒绝,不保证真实业务QPS。默认流超时600秒。会话保存在Redis,默认写入后15天过期;新问题重置本轮主体,未实现普通聊天历史理解。ID不存在/过期不会返回404,而是新建状态。 尚未实现跨进程会话锁、用户登录/归属校验、跨域CORS、历史列表或删除接口;前端通常通过同源代理接入。反向代理需关闭响应缓冲并配置合理超时。本接口没有新增天气、实时新闻或办理进度查询能力。 ## 11. 实现对照 - [HTTP校验、SSE封装与容量控制](../src/step4_api/app.py) - [事件顺序、最终结果与会话恢复](../src/step4_api/runtime.py) - [阶段提示](../src/step4_api/progress.py) - [推荐与来源字段](../src/step3_question_answer/recommendation.py) - [卡片及综合说明](../src/step3_question_answer/cards.py) - [主体与政策比较](../src/step3_question_answer/matching.py)