看不懂英文key或value时,先查 字段与取值中文对照表:逐层说明字段中文名称、状态/类型/原因的含义,以及前端该展示哪些内容。
适用版本:2026-09-14,当前F026–F028实现。服务名称:青浦区营商助手。本文供前端与接口调用方对接,重点说明实际返回格式;启动与部署配置见 第四步服务说明。
一次请求返回一串SSE事件,不是一个普通JSON响应。 所有事件使用相同外层结构;普通聊天返回文字,政策与具体需求还会返回来源、卡片、综合说明。本文政策和企业名称均为合成示例,不代表存在对应政策或用户具备资格。标注“片段”的示例仅展示部分字段。
| 项目 | 说明 |
|---|---|
| 方法 / 路径 | 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不是用户鉴权凭证。
网络上的一条事件如下,最后一个空行是事件结束标记:
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对象中,没有 type、event 或 code=200 这类统一业务字段。除完整网络示例外,下文各事件的JSON示例都只展示这个内层 data对象。
一个网络数据块可能包含半条事件或多条事件,中文UTF-8字符也可能跨块;不可对每次 reader.read() 的结果直接执行 JSON.parse()。
| 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 | 当前轮次的完整最终快照 |
| 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到达理解为后台刚分析完对应条目。
{"message":"已收到您的问题,正在处理……"}
表示已接纳请求,不表示模型调用或业务处理成功。
{"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。不提供百分比或预计剩余时间。
{"status":"processing","message":"问题仍在处理中,请稍候……","elapsed_seconds":20}
无可发送结果且任务仍执行时按配置间隔发送,默认约10秒;不是严格定时器。elapsed_seconds是本次请求累计耗时的整数秒,不是当前节点耗时,也不是剩余时间。收到done/error后停止等待动画。
身份和能力咨询、普通信息、问候、闲聊等由LLM按原话生成。示例问题:“今天工作好累”。
answer.data:
{"text":"辛苦了,先歇一歇,喝点水,给自己留一点放松的时间。"}
result.data:
{
"response": "辛苦了,先歇一歇,喝点水,给自己留一点放松的时间。",
"recommendation": {},
"errors": []
}
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{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_failed 或 direct_response_unavailable,不能只凭done判断内容生成成功。
两类共用推荐数据结构,区别如下:
| 内容 | 了解政策 | 具体需求 |
|---|---|---|
| 示例 | “有哪些创业扶持政策?” | “我想开店但资金不足” |
| 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会丢失主要业务内容。
以下为“成功检索但没有相关依据”的合成示例;有推荐时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等字段。
下面是政策介绍模式的合成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。
以下为具体需求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;目前再输入答案会开始一个新问题,并未实现基于旧卡片的增量资格重算。需要补充时前端可让用户同时带上原问题和新条件。
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为知识快照标识,不代表政策生效日期。
{
"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,需独立检查。
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填“不需要”“无需公司信息”或 /skip。schema是内部补问说明,不要将needs_company/company_hint作为额外HTTP参数提交。
以下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。
{"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。
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重放或按事件断点续传。
items=[]、complete=true:可能没有对应依据,也可能全部条目被筛除;结合overview、sources和excluded_items解释,不说“政策不存在”。complete=false或analysis_errors非空:存在检索/分析/必要企业信息等缺口,不是正常无结果。done.status=completed只表示本轮流程结束,不能代替这些业务判断。满足条款、推荐排序、完整检索均不等于获批。
可将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。新问题不要无条件自动重试,以免重复生成或外部调用。
当前单进程运行,同会话排他;默认同时16个问答,超额拒绝,不保证真实业务QPS。默认流超时600秒。会话保存在Redis,默认写入后15天过期;新问题重置本轮主体,未实现普通聊天历史理解。ID不存在/过期不会返回404,而是新建状态。
尚未实现跨进程会话锁、用户登录/归属校验、跨域CORS、历史列表或删除接口;前端通常通过同源代理接入。反向代理需关闭响应缓冲并配置合理超时。本接口没有新增天气、实时新闻或办理进度查询能力。