api-chat.md 31 KB

POST /api/chat 接口文档

看不懂英文key或value时,先查 字段与取值中文对照表:逐层说明字段中文名称、状态/类型/原因的含义,以及前端该展示哪些内容。

适用版本:2026-09-14,当前F026–F028实现。服务名称:青浦区营商助手。本文供前端与接口调用方对接,重点说明实际返回格式;启动与部署配置见 第四步服务说明

一次请求返回一串SSE事件,不是一个普通JSON响应。 所有事件使用相同外层结构;普通聊天返回文字,政策与具体需求还会返回来源、卡片、综合说明。本文政策和企业名称均为合成示例,不代表存在对应政策或用户具备资格。标注“片段”的示例仅展示部分字段。

1. 接口与请求

项目 说明
方法 / 路径 POST /api/chat
地址 http://<API_HOST>:<API_PORT>/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,监听地址和端口由环境配置控制
{
  "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. 返回的两层结构

网络上的一条事件如下,最后一个空行是事件结束标记

event: progress
data: {"thread_id":"window-20260914-001","request_id":"a78b17a3b38241fa83c8ee91f40ec608","data":{"stage":"retrieve","status":"running","message":"正在检索相关政策和配套服务……"}}

event: 指定事件名称;data: 后面才是可用 JSON.parse() 解析的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对象中,没有 typeeventcode=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 当前轮次的完整最终快照
answer_start stream_id、target、sequence、provisional 正文流开始(F073):建立临时正文块
answer_delta stream_id、target、sequence、text 正文增量:立即渲染(不是整段到达后逐字播放)
answer_end stream_id、target、sequence、text、operation、status 正文定稿:用 text 整块替换临时正文
answer_abort stream_id、reason 撤去临时正文(草稿不可当成功答复)
done status 本次流正常结束或等待输入
error code,可选message 本次流异常结束

正常顺序(仅表示零个或多个事件,不是协议字符):

普通对话: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

{"message":"已收到您的问题,正在处理……"}

表示已接纳请求,不表示模型调用或业务处理成功。

progress

{"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

{"status":"processing","message":"问题仍在处理中,请稍候……","elapsed_seconds":20}

无可发送结果且任务仍执行时按配置间隔发送,默认约10秒;不是严格定时器。elapsed_seconds是本次请求累计耗时的整数秒,不是当前节点耗时,也不是剩余时间。收到done/error后停止等待动画。

5. 普通对话:文字结果

身份和能力咨询、普通信息、问候、闲聊等由LLM按原话生成。示例问题:“今天工作好累”。

answer.data

{"text":"辛苦了,先歇一歇,喝点水,给自己留一点放松的时间。"}

result.data

{
  "response": "辛苦了,先歇一歇,喝点水,给自己留一点放松的时间。",
  "recommendation": {},
  "errors": []
}

正文真实增量流式(F073)——实测补充

契约全文见 token-streaming.md前端实测到的字段关系(2026-09-20, 两份真实载荷;文档没写、但对前端很关键):

字段 有卡片的轮次 无卡片的轮次
answer.text(开场概述) 134 字,是正文开头的压缩版(截断在词中间、空白折叠) 不存在
流式正文 = answer_end.text = summary.text 2199 字(完整正文) 1266 字
result.response 134 字(= 开场概述,兼容旧客户端) = 完整正文

前端处理:流式目标实测都是 summary;正文以 answer_end 为准, summary.text 不再重复渲染(跳过),answer 概述在「与正文公共前缀 ≥90%」时也不显示 (否则开头会重复一遍),只有确认是另一段内容时才保留。 版式:正文是整篇综合说明,政策卡片改排到正文之上(否则用户要先滚过全文才看到卡片, 与改造前「短开场 → 卡片 → 综合说明」不一致);参考资料/二维码仍在正文之下。 notices 仍照常展示。另:该轮 summary.notices 可能是 「本轮检索或逐条分析未完整完成」这类提示,属正常返回。

cipa(result.data.cipa

布尔值,表示本轮是否需要展示企业微信二维码(2026-09-20 后端新增):

取值 含义 前端行为
true 问的是 CIPA / 海外服务平台 / 出海服务 参考资料之前追加一张企业微信二维码(<image-scope> 块,点击可放大)
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 实测, 取自一次真实请求;只列关键字段,实际字段更多):

{
  "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)—— 分类接口的请求体就是它,不要包外层、不要塞 thread_id
  • ⚠️ 实测:分类接口对精简/合成的请求体一律 422,必须给完整的 {company, profile, honors} 结构(2026-09-18:文档里那个合成片段也被拒,换成真实抓下来的整份才 200)

done.data

{"status":"completed"}

answer.text和result.response是同一回复,后者不要再追加一次。没有item/source/summary。普通对话的内部分类目前不单独暴露在result中,前端无需区分service_intro、smalltalk或general_info才能展示。

模型调用或生成格式失败时,可能仍收到固定兜底文字、result及done=completed。应检查result.errors,例如 intent_analysis_faileddirect_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用后续小节的对象填充:

{
  "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,展示完整顶层字段:

{
  "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),展示数组元素结构:

{
  "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在此只展示部分字段:

{
  "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:综合说明

{
  "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示例:

{
  "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仍只传两个字段:

{"thread_id":"window-20260914-001","question":"合成示例有限公司"}

无需公司信息则question填“不需要”“无需公司信息”或 /skipschema是内部补问说明,不要将needs_company/company_hint作为额外HTTP参数提交。

company_selection

以下interrupt.data为部分字段示例:

{
  "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错误

{"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事件

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错误;回调接收事件名和完整外层对象:

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. 实现对照