系列名称:企业级医药 AI 智能客服项目实战
本文主题:项目整体架构与一次聊天请求的完整调用链
适合读者:刚接手 Spring Boot、Vue 和 AI 应用项目,希望先建立整体认识的开发者
关键词:Spring Boot、Vue、RAG、AI 工作流、SSE、大模型
🐟 这里是yurenpai
27届开发者,主要学习 Java 后端与 AI 应用开发。
这里记录真实项目中的代码调用链、Agent/RAG 工程化、问题排查和开发复盘。
个人理念:先把一次真实请求走过的链路画清楚,再去看每个类,理解会比死记类名快得多。

写在前面
先说结论:这个项目不是“Vue 页面调用一个大模型接口”这么简单,而是一个以对话为统一入口,把医药知识、客服规则、真实业务系统、AI 工作流和安全控制组织在一起的企业级智能客服平台。
我刚接手项目时,最容易被三个问题困住:
为了避免全文变成类名和模块名的堆砌,本文使用一个真实测试场景贯穿主链路:
用户:我吃药后呼吸困难,还能继续吃吗?
这句话看起来只是一次普通提问,实际上同时涉及:
- 医疗紧急风险识别;
- 客服场景路由;
- 固定安全回复;
- SSE 流式事件;
- 用户消息与助手消息保存;
- 当前窗口连接关闭。

读完本文后,我们应该能够回答:
- 这句话从用户端页面发出后,会经过哪些系统;
- 为什么 ChatController 不是核心业务层;
- ChatServiceFacade、ChatContextResolver 和 ChatSceneRouter 各自负责什么;
- 为什么医疗高风险问题不会直接进入普通大模型生成;
- SSE、MySQL、Redis、向量库和大模型分别承担什么职责;
- 当前哪些能力已经完成,哪些仍然只是场景入口或后续计划。
一、业务背景:这不是一个普通聊天机器人
先说结论:项目虽然以“聊天”作为用户入口,但真正要完成的是医药客服场景下的安全判断、知识问答和业务办理,而不是做一个什么都回答的通用机器人。
从项目方案来看,AI 智能客服围绕四个方向建设:
| M01 | 智能问答与物流查询 | 回答客服问题,并查询订单、处方生产进度、物流轨迹、售后和异常件状态 |
| M02 | 用药知识智能应答 | 基于企业知识库回答用法用量、禁忌、煎药方法和药材处理等问题 |
| M03 | 线上自助下单 | 引导处方上传、方案选择、地址确认、支付、订单提交和复购 |
| M04 | 用户分层精准推送 | 基于合法授权的客户资料和业务行为进行分层触达并分析效果 |
四类能力都可能从聊天窗口进入,但后端执行方式并不相同:
- 用药知识问题可以进入知识库检索和大模型链路;
- 订单、物流、处方、售后等实时状态必须调用真实业务接口;
- 紧急情况、严重不良反应、停药换药等问题需要优先执行确定性的安全规则;
- 下单、支付等写操作需要鉴权、参数校验和二次确认;
- 用户连续说“它”“这个”“刚才那个订单”时,还需要结合当前用户的历史消息解析上下文。
因此,项目真正要解决的问题是:
如何在同一个聊天入口中,先确认当前用户和问题场景,再选择安全、可靠、可追踪的执行方式。
1.1 四大业务模块共享一套公共底座
#mermaid-svg-KVG5NxItwRyNWEE9{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-KVG5NxItwRyNWEE9 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KVG5NxItwRyNWEE9 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KVG5NxItwRyNWEE9 .error-icon{fill:#552222;}#mermaid-svg-KVG5NxItwRyNWEE9 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KVG5NxItwRyNWEE9 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KVG5NxItwRyNWEE9 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KVG5NxItwRyNWEE9 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KVG5NxItwRyNWEE9 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KVG5NxItwRyNWEE9 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KVG5NxItwRyNWEE9 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KVG5NxItwRyNWEE9 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KVG5NxItwRyNWEE9 .marker.cross{stroke:#333333;}#mermaid-svg-KVG5NxItwRyNWEE9 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KVG5NxItwRyNWEE9 p{margin:0;}#mermaid-svg-KVG5NxItwRyNWEE9 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-KVG5NxItwRyNWEE9 .cluster-label text{fill:#333;}#mermaid-svg-KVG5NxItwRyNWEE9 .cluster-label span{color:#333;}#mermaid-svg-KVG5NxItwRyNWEE9 .cluster-label span p{background-color:transparent;}#mermaid-svg-KVG5NxItwRyNWEE9 .label text,#mermaid-svg-KVG5NxItwRyNWEE9 span{fill:#333;color:#333;}#mermaid-svg-KVG5NxItwRyNWEE9 .node rect,#mermaid-svg-KVG5NxItwRyNWEE9 .node circle,#mermaid-svg-KVG5NxItwRyNWEE9 .node ellipse,#mermaid-svg-KVG5NxItwRyNWEE9 .node polygon,#mermaid-svg-KVG5NxItwRyNWEE9 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-KVG5NxItwRyNWEE9 .rough-node .label text,#mermaid-svg-KVG5NxItwRyNWEE9 .node .label text,#mermaid-svg-KVG5NxItwRyNWEE9 .image-shape .label,#mermaid-svg-KVG5NxItwRyNWEE9 .icon-shape .label{text-anchor:middle;}#mermaid-svg-KVG5NxItwRyNWEE9 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-KVG5NxItwRyNWEE9 .rough-node .label,#mermaid-svg-KVG5NxItwRyNWEE9 .node .label,#mermaid-svg-KVG5NxItwRyNWEE9 .image-shape .label,#mermaid-svg-KVG5NxItwRyNWEE9 .icon-shape .label{text-align:center;}#mermaid-svg-KVG5NxItwRyNWEE9 .node.clickable{cursor:pointer;}#mermaid-svg-KVG5NxItwRyNWEE9 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-KVG5NxItwRyNWEE9 .arrowheadPath{fill:#333333;}#mermaid-svg-KVG5NxItwRyNWEE9 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-KVG5NxItwRyNWEE9 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-KVG5NxItwRyNWEE9 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KVG5NxItwRyNWEE9 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-KVG5NxItwRyNWEE9 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KVG5NxItwRyNWEE9 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-KVG5NxItwRyNWEE9 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-KVG5NxItwRyNWEE9 .cluster text{fill:#333;}#mermaid-svg-KVG5NxItwRyNWEE9 .cluster span{color:#333;}#mermaid-svg-KVG5NxItwRyNWEE9 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-KVG5NxItwRyNWEE9 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-KVG5NxItwRyNWEE9 rect.text{fill:none;stroke-width:0;}#mermaid-svg-KVG5NxItwRyNWEE9 .icon-shape,#mermaid-svg-KVG5NxItwRyNWEE9 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KVG5NxItwRyNWEE9 .icon-shape p,#mermaid-svg-KVG5NxItwRyNWEE9 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-KVG5NxItwRyNWEE9 .icon-shape .label rect,#mermaid-svg-KVG5NxItwRyNWEE9 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KVG5NxItwRyNWEE9 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-KVG5NxItwRyNWEE9 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-KVG5NxItwRyNWEE9 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
AI 智能客服公共底座
M01 智能问答与物流查询
M02 用药知识智能应答
M03 线上自助下单
M04 用户分层精准推送
登录与用户身份
会话与消息保存
医疗风险与场景路由
上下文理解
SSE 流式通信
RAG 与大模型
权限、异常与日志
如果发布平台不显示 Mermaid,可以把它理解为:
登录、会话、路由、上下文、SSE、RAG 和权限等公共能力
→ 共同支撑智能问答、用药应答、自助下单和精准推送
小鱼点睛
聊天窗口只是统一入口,不代表所有问题都应该交给大模型。真正的企业级能力,是先判断“该由谁处理”,再执行对应链路。
二、三个代码仓库分别负责什么
先说结论:三个仓库不是重复项目,而是分别承担用户交互、运营配置和后端业务编排。
tst-pharma-customer-web 用户端前端
tst-pharma-scene-admin-web 管理端前端
tst-pharma-main-backend Spring Boot 后端

2.1 仓库职责
| tst-pharma-customer-web | 最终用户 | 登录、会话列表、消息展示、模型或知识库选择、SSE 内容消费 | 不负责医疗安全决策和真实业务权限校验 |
| tst-pharma-scene-admin-web | 运营和管理人员 | 模型、知识库、工作流、系统配置和管理页面 | 不直接承担用户聊天主流程 |
| tst-pharma-main-backend | 两个前端及其他调用方 | 认证、会话、消息、路由、知识库、模型、工作流、SSE 和业务编排 | 不负责浏览器页面展示 |
2.2 为什么需要两个前端
用户端关心的是:
能不能登录
→ 能不能发送消息
→ 回复能不能流式显示
→ 历史会话能不能继续
管理端关心的是:
使用哪个模型
→ 知识库是否可用
→ 文档是否入库
→ 工作流怎样配置
→ 系统参数怎样维护
把两个前端分开,可以让面向用户的聊天体验和面向运营的配置能力保持清晰边界。前端可以改善交互,但不能代替后端的身份校验、医疗风险判断和数据权限控制。
三、从前端到大模型:先看完整系统架构
先说结论:一次聊天请求至少跨越表现层、接口层、应用编排层、场景决策层、执行层和基础设施层。只有需要生成式回答的问题,才会真正走到 RAG 或大模型。
#mermaid-svg-nAI87vapeHXpGG3W{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-nAI87vapeHXpGG3W .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-nAI87vapeHXpGG3W .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-nAI87vapeHXpGG3W .error-icon{fill:#552222;}#mermaid-svg-nAI87vapeHXpGG3W .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-nAI87vapeHXpGG3W .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-nAI87vapeHXpGG3W .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-nAI87vapeHXpGG3W .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-nAI87vapeHXpGG3W .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-nAI87vapeHXpGG3W .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-nAI87vapeHXpGG3W .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-nAI87vapeHXpGG3W .marker{fill:#333333;stroke:#333333;}#mermaid-svg-nAI87vapeHXpGG3W .marker.cross{stroke:#333333;}#mermaid-svg-nAI87vapeHXpGG3W svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-nAI87vapeHXpGG3W p{margin:0;}#mermaid-svg-nAI87vapeHXpGG3W .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-nAI87vapeHXpGG3W .cluster-label text{fill:#333;}#mermaid-svg-nAI87vapeHXpGG3W .cluster-label span{color:#333;}#mermaid-svg-nAI87vapeHXpGG3W .cluster-label span p{background-color:transparent;}#mermaid-svg-nAI87vapeHXpGG3W .label text,#mermaid-svg-nAI87vapeHXpGG3W span{fill:#333;color:#333;}#mermaid-svg-nAI87vapeHXpGG3W .node rect,#mermaid-svg-nAI87vapeHXpGG3W .node circle,#mermaid-svg-nAI87vapeHXpGG3W .node ellipse,#mermaid-svg-nAI87vapeHXpGG3W .node polygon,#mermaid-svg-nAI87vapeHXpGG3W .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-nAI87vapeHXpGG3W .rough-node .label text,#mermaid-svg-nAI87vapeHXpGG3W .node .label text,#mermaid-svg-nAI87vapeHXpGG3W .image-shape .label,#mermaid-svg-nAI87vapeHXpGG3W .icon-shape .label{text-anchor:middle;}#mermaid-svg-nAI87vapeHXpGG3W .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-nAI87vapeHXpGG3W .rough-node .label,#mermaid-svg-nAI87vapeHXpGG3W .node .label,#mermaid-svg-nAI87vapeHXpGG3W .image-shape .label,#mermaid-svg-nAI87vapeHXpGG3W .icon-shape .label{text-align:center;}#mermaid-svg-nAI87vapeHXpGG3W .node.clickable{cursor:pointer;}#mermaid-svg-nAI87vapeHXpGG3W .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-nAI87vapeHXpGG3W .arrowheadPath{fill:#333333;}#mermaid-svg-nAI87vapeHXpGG3W .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-nAI87vapeHXpGG3W .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-nAI87vapeHXpGG3W .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nAI87vapeHXpGG3W .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-nAI87vapeHXpGG3W .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nAI87vapeHXpGG3W .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-nAI87vapeHXpGG3W .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-nAI87vapeHXpGG3W .cluster text{fill:#333;}#mermaid-svg-nAI87vapeHXpGG3W .cluster span{color:#333;}#mermaid-svg-nAI87vapeHXpGG3W div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-nAI87vapeHXpGG3W .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-nAI87vapeHXpGG3W rect.text{fill:none;stroke-width:0;}#mermaid-svg-nAI87vapeHXpGG3W .icon-shape,#mermaid-svg-nAI87vapeHXpGG3W .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nAI87vapeHXpGG3W .icon-shape p,#mermaid-svg-nAI87vapeHXpGG3W .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-nAI87vapeHXpGG3W .icon-shape .label rect,#mermaid-svg-nAI87vapeHXpGG3W .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nAI87vapeHXpGG3W .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-nAI87vapeHXpGG3W .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-nAI87vapeHXpGG3W :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
POST /chat/send
固定安全回复/范围引导/澄清
继续聊天
工作流模式
订单场景且有订单号
物流/处方/售后
用户端 Vue
ChatController
管理端 Vue
模型/知识库/工作流配置
ChatServiceFacade
ChatContextResolver
ChatSceneRouter
FixedReplyService
RAG 与大模型
AI 工作流
BusinessQueryReplyService
OrderQueryGateway
OrderModuleQueryGateway
tst-pharma-order / OrderQueryService
MySQL / biz_order
业务查询入口,尚未全部接入
SSE
MySQL
Redis
向量库
大模型服务
这张图中最重要的不是技术名词,而是两次分流:

用纯文本表示主链路就是:
用户输入
→ ChatController 接收
→ ChatServiceFacade 编排
→ ChatContextResolver 构造轻量上下文
→ ChatSceneRouter 决定下一步动作
→ 固定回复 / 工作流 / 业务接口 / RAG 与大模型
→ SSE 返回
→ 保存消息并关闭当前连接
这里还需要注意一个事实:订单查询已经接入聊天主流程、订单模块和数据库查询代码,但尚未使用当前真实数据库完成端到端联调;物流、处方和售后虽然已有部分查询契约、状态模型或数据库适配代码,仍未全部接入聊天主流程。真实完成状态会在后文单独区分。
四、后端模块和聊天代码在哪里
先说结论:阅读这次 AI 客服改造时,不需要从整个后端仓库逐文件翻起。应先定位 tst-pharma-chat 的聊天入口、scene 路由目录和 business 业务适配目录;阅读订单查询时,再继续进入 tst-pharma-order 模块。
后端主要结构可以简化为:
tst-pharma-main-backend/
├─ tst-pharma-admin/ Spring Boot 启动与接口聚合
├─ tst-pharma-common/ 公共 DTO、枚举、工具和基础能力
├─ tst-pharma-modules/
│ ├─ tst-pharma-chat/ 聊天、模型调用、场景路由和 SSE 主链路
│ ├─ tst-pharma-order/ 订单领域查询、订单实体与 Mapper
│ ├─ tst-pharma-aiflow/ AI 工作流相关能力
│ ├─ tst-pharma-system/ 用户、权限和系统管理
│ ├─ tst-pharma-generator/ 代码生成相关能力
│ └─ tst-pharma-workflow/ 工作流相关能力
└─ tst-pharma-extend/ 扩展服务
与本文关系最紧密的聊天场景目录是:
tst-pharma-modules/tst-pharma-chat/src/main/java/com/tst/pharma/
├─ controller/chat/
│ └─ ChatController.java
└─ service/chat/
├─ business/
│ ├─ gateway/
│ │ ├─ OrderQueryGateway.java
│ │ └─ impl/OrderModuleQueryGateway.java
│ ├─ model/
│ │ └─ OrderStatusSnapshot.java
│ └─ reply/
│ ├─ BusinessQueryReplyService.java
│ └─ OrderQueryReplyFormatter.java
├─ impl/
│ └─ ChatServiceFacade.java
└─ scene/
├─ ChatSceneRouter.java
├─ MedicalRiskClassifier.java
├─ ChatSceneClassifier.java
├─ CustomerServiceScopeClassifier.java
├─ ContextReferenceClassifier.java
├─ OutOfScopeFollowUpClassifier.java
├─ FixedReplyService.java
├─ FixedReplyTemplateProvider.java
├─ context/
│ ├─ ChatContextResolver.java
│ └─ model/
├─ model/
│ ├─ ChatSceneType.java
│ ├─ MedicalRiskLevel.java
│ ├─ ChatRouteAction.java
│ └─ ChatRouteResult.java
├─ rule/
│ └─ CustomerServiceRules.java
└─ sse/
├─ FixedReplySseSender.java
└─ DefaultFixedReplySseSender.java
这个目录可以先按四组理解:
| 入口与编排 | ChatController、ChatServiceFacade | 接收请求并组织整条执行链 |
| 上下文与识别 | ChatContextResolver、多个 Classifier | 从当前请求和最近消息中识别风险、场景和指代 |
| 统一决策模型 | ChatSceneType、MedicalRiskLevel、ChatRouteAction、ChatRouteResult | 用统一结构描述“识别到了什么、风险多高、下一步做什么” |
| 业务查询适配 | BusinessQueryReplyService、各类 Gateway、状态快照与格式化器 | 把聊天场景转换为受身份约束的业务查询,并生成客服回复 |
| 固定回复执行 | FixedReplyService、FixedReplySseSender | 获取安全文案,通过 SSE 发送、保存助手消息并关闭连接 |
目录只是地图。真正理解项目,还需要继续看这些类怎样形成调用链。
五、聊天入口与核心类的职责边界
先说结论:ChatController 只负责把 HTTP 请求交给应用层,真正控制执行顺序的是 ChatServiceFacade;ChatContextResolver 负责准备路由上下文,ChatSceneRouter 负责做决策,FixedReplyService 负责执行固定回复,BusinessQueryReplyService 则负责组织已经接入的订单业务查询。
5.1 ChatController:HTTP 入口,不是业务中心
所在位置:
tst-pharma-modules/tst-pharma-chat/src/main/java/
└─ com/tst/pharma/controller/chat/ChatController.java
项目中的聊天接口非常薄:
@PostMapping("/send")
@ResponseBody
public SseEmitter sseChat(@RequestBody @Valid ChatRequest chatRequest) {
return chatService.sseChat(chatRequest);
}
这段代码只完成三件事:
它不负责判断医疗风险,也不负责选择大模型、保存消息或拼装固定回复。这样做可以避免 Controller 逐渐膨胀成难以测试的“万能类”。
5.2 ChatServiceFacade:整条聊天链路的编排中心
所在位置:
tst-pharma-modules/tst-pharma-chat/src/main/java/
└─ com/tst/pharma/service/chat/impl/ChatServiceFacade.java
ChatServiceFacade.sseChat() 的真实代码较长。下面是为了说明调用顺序整理后的简化示意代码,不是项目源码的完整复制:
public SseEmitter sseChat(ChatRequest request) {
validateImageUrls(request.getImageUrls());
Long userId = getCurrentUserId();
String token = getCurrentTokenValue();
String connectionId = buildSseConnectionId(token, request);
ChatRoutingContext context = chatContextResolver.resolve(request, userId);
ChatRouteResult route = chatSceneRouter.route(context);
SseEmitter emitter = sseEmitterManager.connect(userId, connectionId);
fillServerSideFields(request, userId, token, connectionId, emitter);
saveUserMessage(request);
if (route.getAction() == ChatRouteAction.DIRECT_REPLY) {
return handleCustomerServiceRoute(request, route);
}
SseEmitter workflowEmitter = handleWorkflowOrResume(request);
if (workflowEmitter != null) {
return workflowEmitter;
}
if (route.getAction() != ChatRouteAction.CONTINUE_CHAT) {
return handleCustomerServiceRoute(request, route);
}
return streamFromModel(request);
}
这段顺序说明了两个关键设计:
- 医疗安全固定回复优先于工作流、恢复流程和思考模式;
- 只有真正进入模型聊天时,才读取模型配置、构建上下文并调用流式模型。
也就是说,ChatServiceFacade 不是亲自完成每件事,而是根据路由结果协调不同服务。
5.3 ChatContextResolver:给路由器准备轻量上下文
所在位置:
service/chat/scene/context/ChatContextResolver.java
它的类注释已经明确写出边界:只读取当前请求和最近文本消息,不调用模型、RAG、工作流、业务接口或 SSE。
它主要解决的问题包括:
- 当前用户是谁;
- 当前会话是什么;
- 最近消息中出现了哪些药品、订单、物流、处方或售后实体;
- 用户说“它”“这个”“刚才那个”时,是否有足够证据确认对象;
- 当前输入是否包含图片。
服务端传入的 currentUserId 来自登录态,而不是直接相信请求体中的用户字段。这是用户数据隔离的基础。
5.4 ChatSceneRouter:根据分类结果决定下一步动作
所在位置:
service/chat/scene/ChatSceneRouter.java
它首先执行 MedicalRiskClassifier。如果已经得到 DIRECT_REPLY,就立即返回医疗风险结果;否则再继续处理订单、物流、处方、售后、人工服务、服务范围和上下文澄清等分类。
因此:
分类器:识别“这句话像什么”
路由器:决定“下一步做什么”
执行服务:真正发送回复、调用模型或调用业务接口
ChatRouteResult 把一次决策统一成五部分:
sceneType 场景类型
riskLevel 医疗风险等级
action 下一步动作
matchedRule 命中的规则
replyTemplate 固定回复模板编码
5.5 FixedReplyService:发送、保存并关闭固定回复链路
所在位置:
service/chat/scene/FixedReplyService.java
它接收 ChatRouteResult 后,执行顺序是:
根据 replyTemplate 获取固定文案
→ 发送 SSE content 事件
→ 保存 assistant 消息
→ 发送 SSE done 事件
→ finally 中关闭当前连接
如果中途出现异常,它会尝试发送固定的安全错误文案,再关闭连接,避免把后端异常细节直接暴露给前端。
5.6 核心类放在一起看
| ChatController | 前端 HTTP 请求、ChatRequest | 调用 ChatServiceFacade,返回 SseEmitter | 提供聊天接口入口 | 不做医疗分类、模型调用和消息保存 |
| ChatServiceFacade | ChatController 或跨模块聊天调用 | 路由到固定回复、工作流或模型链路 | 统一编排身份、连接、消息、路由和生成流程 | 不负责定义每条医疗规则,也不负责页面展示 |
| ChatContextResolver | ChatServiceFacade、当前请求、服务端用户 ID | ChatRoutingContext,交给 ChatSceneRouter | 构造路由前需要的轻量上下文 | 不调用模型、RAG、业务接口和 SSE |
| ChatSceneRouter | ChatRoutingContext | ChatRouteResult,返回 ChatServiceFacade | 组合分类结果并决定下一步动作 | 不发送 SSE,不保存消息,不生成最终回复 |
| FixedReplyService | ChatServiceFacade、请求、路由结果 | SSE 事件、助手消息记录、连接完成 | 执行固定回复完整生命周期 | 不重新判断场景,不调用大模型 |
5.7 新增的订单查询业务链路
订单查询是当前代码中第一个从“识别业务场景”继续走到真实领域模块的业务链路。它的主干是:
ChatSceneRouter
→ 从上下文提取订单号
→ BusinessQueryReplyService
→ OrderQueryGateway
→ OrderModuleQueryGateway
→ OrderQueryService
→ OrderQueryServiceImpl
→ BizOrderMapper / biz_order
→ OrderStatusData / OrderStatusSnapshot
→ OrderQueryReplyFormatter
→ SSE content
→ 保存 assistant 消息
→ SSE done
→ complete
各类的职责边界如下:
| BusinessQueryReplyService | 组织订单查询、结果格式化、SSE 发送和助手消息保存 | 不直接编写数据库查询条件 |
| OrderQueryGateway | 定义聊天层需要的订单查询端口 | 不依赖订单表和 Mapper 的实现细节 |
| OrderModuleQueryGateway | 把聊天层查询参数适配到订单模块 | 不负责最终客服文案 |
| OrderQueryService | 提供订单模块对外稳定 API | 不处理 SSE 和聊天消息入库 |
| OrderQueryServiceImpl | 按租户、用户和订单号查询数据库并返回订单状态 | 不负责场景识别和自然语言表达 |
| OrderQueryReplyFormatter | 把成功、未找到、无权、参数错误等结果转换成客服文案 | 不负责查询数据库 |
查询条件同时包含 tenantId、userId、orderNo 和未删除标记,说明订单查询不是仅凭订单号读取任意记录,而是把租户与当前登录用户的归属校验放进查询链路。

[RISK] 名称与真实行为存在阶段性差异
ChatRouteAction.BUSINESS_PLACEHOLDER 这个枚举名称尚未重命名,但 ChatServiceFacade 已经对 ORDER_QUERY 做了特判:有明确订单号时会进入真实订单查询服务。因此,不能只看枚举名称就断定订单仍然只返回占位文案。
小鱼点睛
Facade 负责“排顺序”,Resolver 负责“补上下文”,Router 负责“做决策”,FixedReplyService 负责“执行固定回复”。把职责分开后,每一层才能独立测试。
六、真实案例推演:一句“呼吸困难”怎样走完整链路
现在回到全文的主案例:
用户:我吃药后呼吸困难,还能继续吃吗?
6.1 用户端发起聊天请求
用户端把会话 ID、消息内容、模型选择等字段提交到:
POST /chat/send
前端的职责是发起请求并消费 SSE 事件。它不能仅凭页面规则决定这是医疗紧急情况,因为接口还可能被其他客户端直接调用,安全判断必须由后端统一执行。
6.2 ChatController 转交请求
ChatController.sseChat() 接收 ChatRequest 后,直接调用:
ChatServiceFacade.sseChat(chatRequest)
此时还没有调用大模型。
6.3 ChatServiceFacade 获取服务端身份并构造连接标识
ChatServiceFacade 从登录上下文中读取:
currentUserId
currentTokenValue
然后结合请求构造窗口级 SSE 连接标识。这样做是为了支持同一个登录用户在不同会话或窗口中分别接收消息,避免只使用一个 Token 时相互覆盖。
接着,服务会把服务端确认的 userId、Token、连接标识和 SseEmitter 写回当前 ChatRequest,供后续链路使用。
6.4 ChatContextResolver 构造当前路由上下文
ChatContextResolver 读取当前输入和最近消息,形成 ChatRoutingContext。
对于本例,当前文本已经明确出现“吃药”和“呼吸困难”,不需要依赖上一轮才能判断医疗紧急风险。即便如此,统一构造上下文仍然有价值,因为订单、物流和指代问题可能需要最近消息才能判断。
6.5 MedicalRiskClassifier 优先识别紧急风险
ChatSceneRouter 首先调用 MedicalRiskClassifier。呼吸困难 命中紧急风险规则后,产生的路由结果可以概括为:
sceneType = MEDICAL_EMERGENCY
riskLevel = CRITICAL
action = DIRECT_REPLY
matchedRule = 医疗紧急风险规则
replyTemplate = 紧急风险固定回复模板
这里最关键的是 DIRECT_REPLY。它表示当前请求不能继续自由生成,而是要执行预先审核的固定安全文案。
6.6 ChatServiceFacade 优先执行固定回复
ChatServiceFacade 在保存用户原始消息后,会先判断:
if (routeResult.getAction() == ChatRouteAction.DIRECT_REPLY) {
return handleCustomerServiceRoute(chatRequest, routeResult);
}
因为这个判断位于工作流、思考模式和普通模型调用之前,所以即使请求中同时开启了其他模式,医疗紧急风险仍然优先。
6.7 FixedReplyService 完成 SSE 和消息入库
FixedReplyTemplateProvider 根据模板编码取得已配置的紧急安全文案,大意是:当前情况可能存在紧急风险,AI 不能代替医生判断,应立即就医或联系急救和专业医务人员。
随后执行:
SSE content:把固定回复发给当前窗口
→ MySQL:保存 assistant 消息
→ SSE done:告诉前端本次回复已完成
→ complete:关闭当前窗口对应的 SSE 连接
```
最终,这句话不会进入普通 RAG 或大模型生成链路。
### 6.8 完整时序图
```mermaid
sequenceDiagram
participant U as 用户端
participant C as ChatController
participant F as ChatServiceFacade
participant X as ChatContextResolver
participant R as ChatSceneRouter
participant M as MedicalRiskClassifier
participant P as FixedReplyService
participant D as 消息存储
participant S as SSE
U->>C: POST /chat/send
C->>F: sseChat(ChatRequest)
F->>X: resolve(request, currentUserId)
X–>>F: ChatRoutingContext
F->>R: route(context)
R->>M: classify(content)
M–>>R: MEDICAL_EMERGENCY / CRITICAL / DIRECT_REPLY
R–>>F: ChatRouteResult
F->>S: 建立当前窗口连接
F->>D: 保存 user 消息
F->>P: sendFixedReply(…)
P->>S: content
P->>D: 保存 assistant 消息
P->>S: done
P->>S: complete
这条链路也解释了为什么“固定回复”不能只在前端弹出一句话:如果不保存助手消息,用户刷新历史会话后就看不到这次正式回复;如果不发送 done 和关闭连接,前端可能一直保持加载状态。
七、并不是所有问题都走同一条执行路径
主案例属于医疗紧急风险,但项目还需要处理其他场景。当前代码中的主要分流可以概括为:
| “某个药有哪些注意事项?” | CONTINUE_CHAT | 知识库 RAG 与大模型 | 已存在聊天、知识库和模型主链能力,仍需以具体知识库内容和环境为准 |
| “查询订单,订单号是 XXX” | BUSINESS_PLACEHOLDER | 真实订单查询链路 | 已接入代码和单元测试,待使用当前真实数据库完成端到端联调 |
| “订单现在到哪一步了?”但没有订单号 | CLARIFY | 要求用户补充订单号 | 不猜测订单,也不绕过当前用户的数据归属校验 |
| “快递到哪里了?” | BUSINESS_PLACEHOLDER | 物流固定占位回复 | 已有查询契约、状态模型、实体和 Mapper,但尚未接入聊天主流程 |
| “处方审核到哪一步了?” | BUSINESS_PLACEHOLDER | 处方固定占位回复 | 已有处方查询 Gateway 和数据库适配器,但尚未接入聊天主流程 |
| “售后处理到哪一步了?” | BUSINESS_PLACEHOLDER | 售后固定占位回复 | 已有查询契约、状态模型、实体和 Mapper,但尚未接入聊天主流程 |
| “帮我写一段 Java 代码” | SCOPE_GUIDE | 返回客服服务范围引导 | 不进入通用模型自由回答 |
| “它还能继续吃吗?”但历史对象不明确 | CLARIFY | 要求补充药品名称、编号或图片 | 通过上下文和指代规则避免猜测 |
| 明确要求人工处理 | HUMAN_GUIDE | 引导官方人工客服入口 | 当前返回固定人工引导文案 |
| 请求启用工作流或恢复流程 | 独立工作流分支 | 调用工作流启动或恢复服务 | 与普通模型聊天分开执行 |
这里最容易误解的是 BUSINESS_PLACEHOLDER:它现在是一个仍处于演进中的动作名称,不能再把所有使用该动作的场景都解释成“只返回占位回复”。
ORDER_QUERY + 明确订单号
→ ChatServiceFacade 特判
→ 进入 BusinessQueryReplyService
→ 查询订单模块
物流 / 处方 / 售后
→ 当前仍未接入聊天主流程
→ 不能写成已经完成实时查询
因此,判断功能状态必须继续追踪 ChatServiceFacade 的执行分支,而不能只看枚举名称。订单链路已经进入真实查询代码,但仍需真实数据库端到端联调;物流、处方和售后则仍需要完成聊天入口接入、归属校验、异常处理和联调测试。
八、MySQL、Redis、向量库、大模型和 SSE 分别负责什么

先说结论:这些组件不是同一类能力,不能互相替代。MySQL 保存业务事实,Redis 保存高频临时状态,向量库负责语义检索,大模型负责语言理解与生成,SSE 负责把过程实时推给浏览器。
| MySQL | 持久化用户、会话、消息、模型和业务配置等数据 | 需要长期保存、可查询、可审计的数据 | 不负责生成回答,也不适合作为向量相似度检索引擎 |
| Redis | 缓存、登录态或其他高频临时状态 | 生命周期较短、读取频繁的数据 | 不应代替正式业务库保存唯一事实 |
| 向量库 | 根据语义相似度召回知识片段 | 药品说明、制度文档、常见问答等非结构化知识 | 不负责判断当前用户的真实订单和物流状态 |
| 大模型 | 理解自然语言、组织答案、结合上下文生成回复 | 普通知识问答、语言表达和推理辅助 | 不应凭空编造业务状态,也不应绕过医疗安全规则 |
| SSE | 服务端持续向前端推送事件 | 内容片段、完成事件、错误事件 | 不负责消息持久化和业务决策 |
8.1 两类数据必须分清
规则或知识问题
→ 可以检索知识库
→ 将召回内容交给大模型组织回答
个人实时业务状态
→ 必须使用服务端登录身份
→ 校验数据归属和查询权限
→ 调用订单、处方、物流或售后真实接口
例如“普通快递一般多久送达”可能是知识问题;“我的运单现在到哪里了”则是当前用户的实时业务问题。即使向量库里有物流说明文档,也不能拿文档内容冒充真实物流轨迹。
九、必须区分四层状态:规划、代码、环境和测试
先说结论:看到需求文档、代码类、服务已启动或单元测试通过,都不能直接推导出“整个功能已经完成”。真实企业项目至少要把四层状态分开描述。
| 方案规划 | 项目最终准备做什么 | 已规划 M01 智能问答与物流查询、M02 用药知识智能应答、M03 线上自助下单、M04 用户分层精准推送 |
| 代码实现 | 仓库中已经写了什么 | 已存在医疗风险路由、客服范围限制、固定回复、上下文解析、SSE 多窗口标识与异常关闭;订单查询契约、订单模块和客服主流程也已接入 |
| 环境联通 | 当前依赖能否连接和启动 | 此前已完成前后端、MySQL、Redis和测试知识库的基础联通;具体环境仍需在每次联调前重新确认 |
| 测试验证 | 哪些行为已被验证 | tst-pharma-chat 与 tst-pharma-order 的 230 个相关单元测试全部通过;订单真实数据库端到端联调、物流、处方和售后聊天主流程仍不能据此视为完成 |
可以把它记成:
需求里写了 ≠ 代码已经实现
代码类存在 ≠ 外部服务已经联通
服务能够启动 ≠ 业务结果一定正确
单元测试通过 ≠ 真实端到端流程已经完成
以物流查询为例,当前代码能够识别物流意图,并返回“暂不支持实时物流轨迹”的安全占位回复。这说明路由入口已经存在,但还需要完成:
运单号或订单号解析
→ 当前用户数据归属校验
→ 真实物流接口调用
→ 物流节点统一转换
→ 超时和异常处理
→ 前端展示
→ 联调测试
所以,文章中不能把“识别到物流场景”写成“物流查询功能已经完成”。
十、测试与验证:我怎样确认这条链路不是只停留在设计上
先说结论:本文中的类关系来自当前代码,测试结论来自实际 Maven 测试报告,而不是根据类名推测。本次同时验证了聊天模块和订单模块,但单元测试仍不等同于真实数据库与完整线上环境验收。
10.1 测试目标
本次主要验证:
10.2 测试环境
JDK:17.0.19
构建工具:IntelliJ IDEA 内置 Maven
测试模块:tst-pharma-modules/tst-pharma-chat、tst-pharma-modules/tst-pharma-order
代码分支:wuqi
后端提交:bf0fb6a
项目根 pom.xml 默认配置了:
<skipTests>true</skipTests>
所以只执行普通 Maven 构建可能看到 BUILD SUCCESS,但测试实际上被跳过。为了真正运行测试,本次显式传入了 -DskipTests=false。
10.3 实际执行命令
mvn –pl tst-pharma-modules/tst-pharma-chat,tst-pharma-modules/tst-pharma-order `
–am `
–DskipTests=false `
–Dtest=ChatControllerTest,ChatServiceFacadeTest,ChatSceneClassifierTest,ChatSceneRouterTest,ContextReferenceClassifierTest,CustomerServiceScopeClassifierTest,FixedReplyServiceTest,FixedReplyTemplateProviderTest,MedicalRiskClassifierTest,OutOfScopeFollowUpClassifierTest,ChatContextResolverTest,BusinessQueryContractTest,BusinessQueryResultTest,DatabasePrescriptionQueryGatewayTest,OrderModuleQueryGatewayTest,BusinessQueryReplyServiceTest,OrderQueryReplyFormatterTest,OrderQueryServiceImplTest `
–Dsurefire.failIfNoSpecifiedTests=false `
test
10.4 测试结果
Tests run: 230
Failures: 0
Errors: 0
Skipped: 0
BUILD SUCCESS
按测试类统计,本次实际覆盖了:
| BusinessQueryContractTest | 4 | 通过 |
| BusinessQueryReplyServiceTest | 6 | 通过 |
| BusinessQueryResultTest | 15 | 通过 |
| ChatContextResolverTest | 15 | 通过 |
| ChatControllerTest | 1 | 通过 |
| ChatSceneClassifierTest | 12 | 通过 |
| ChatSceneRouterTest | 39 | 通过 |
| ChatServiceFacadeTest | 17 | 通过 |
| ContextReferenceClassifierTest | 18 | 通过 |
| CustomerServiceScopeClassifierTest | 17 | 通过 |
| DatabasePrescriptionQueryGatewayTest | 7 | 通过 |
| FixedReplyServiceTest | 9 | 通过 |
| FixedReplyTemplateProviderTest | 13 | 通过 |
| MedicalRiskClassifierTest | 26 | 通过 |
| OrderModuleQueryGatewayTest | 5 | 通过 |
| OrderQueryReplyFormatterTest | 9 | 通过 |
| OutOfScopeFollowUpClassifierTest | 8 | 通过 |
| OrderQueryServiceImplTest | 9 | 通过 |
10.5 关键验证记录
| 医疗紧急风险 | 输入包含“吃药后呼吸困难” | 返回 MEDICAL_EMERGENCY + CRITICAL + DIRECT_REPLY,不进入普通模型 | 对应分类与路由测试通过 | [OK] |
| 固定回复消息保存 | 路由结果要求固定回复 | 发送 content,保存 assistant,发送 done,关闭连接 | FixedReplyServiceTest 和 Facade 相关测试通过 | [OK] |
| 多窗口隔离 | 同一用户和 Token 下使用不同窗口信息 | 生成并使用窗口级连接标识,不让当前回复串到其他窗口 | Facade 相关测试通过 | [OK] |
| 上下文澄清 | 当前只说“它”,历史无法确认唯一对象 | 返回澄清动作,不猜测药品或业务记录 | Resolver、指代和 Router 测试通过 | [OK] |
| 范围外连续追问 | 先问范围外问题,再追问“为什么不能回答” | 继续收口到医药客服服务范围 | Scope、FollowUp 和 Router 测试通过 | [OK] |
| 订单号缺失 | 提出订单查询,但上下文没有明确订单号 | 返回 CLARIFY 并要求补充订单号 | Router 相关测试通过 | [OK] |
| 订单查询链路 | 提供明确订单号并触发订单场景 | 经过 Gateway、Service、Formatter、SSE 和消息保存 | 相关服务与格式化测试通过 | [OK] |
| 租户与用户隔离 | 构造不同租户、用户和订单号条件 | 查询条件同时包含 tenantId、userId、orderNo | OrderQueryServiceImplTest 通过 | [OK] |
| 订单真实数据库联调 | 使用当前环境中的真实用户与订单数据请求聊天接口 | 返回该用户真实订单状态并完成 SSE 生命周期 | 尚未执行当前数据库端到端验证 | [TODO] |
| 真实物流轨迹 | 使用真实用户订单查询物流节点 | 返回该用户的实时物流数据 | 尚未接入聊天主流程 | [TODO] |
需要特别说明:
230 个相关单元测试通过,只证明当前被覆盖的代码行为符合预期,不等于订单真实数据库端到端联调已经完成,也不等于物流、处方和售后已经接入聊天主流程。
测试日志中如果出现被主动构造的异常堆栈,还需要结合 Failures 和 Errors 判断。异常路径测试本来就会触发异常,最终统计为 0 才说明断言通过。
十一、刚接手项目时,正确的代码阅读顺序
先说结论:不要从分类器目录随机点文件,也不要一上来阅读整个 ChatServiceFacade。应当先确认请求数据,再沿主调用链逐层进入。
推荐顺序如下:
ChatRequest
→ ChatController
→ ChatServiceFacade.sseChat()
→ ChatContextResolver
→ ChatSceneRouter
→ MedicalRiskClassifier / ChatSceneClassifier 等分类器
→ ChatRouteResult 和相关枚举
→ FixedReplyService
→ RAG 与流式模型链路
→ AI 工作流链路
11.1 第一步:先看 ChatRequest
先弄清楚前端传入了哪些字段,例如:
- 会话 ID;
- 用户输入内容;
- 模型名称;
- 图片地址;
- 是否启用工作流;
- 是否启用思考模式;
- 工作流或恢复流程参数。
如果不了解请求结构,后面看到大量分支时很难判断它们为什么存在。
11.2 第二步:从 Controller 找到真正入口
确认接口路径是 /chat/send,并看到 Controller 只调用 ChatServiceFacade.sseChat()。到这里就应立刻跳转 Facade,而不是继续研究 Controller 注解。
11.3 第三步:只画 Facade 的主干
第一次阅读 sseChat() 时,只记录:
身份
→ 上下文
→ 路由
→ SSE
→ 用户消息
→ 固定回复/工作流/模型
先不要进入所有私有方法。等主干建立后,再分别查看连接标识、异常关闭、模型回调和工作流处理。
11.4 第四步:区分识别、决策和执行
看到多个 Classifier 时,不要认为它们都会直接回复用户。它们负责识别;ChatSceneRouter 负责合并结果;ChatServiceFacade 和具体执行服务负责采取动作。
11.5 第五步:最后再看基础设施细节
当调用链已经清楚,再查看:
- 消息表怎样保存;
- Redis 在哪些位置使用;
- 向量库怎样检索;
- 模型工厂怎样选择服务提供商;
- SSE 事件格式和连接管理;
- 工作流怎样启动和恢复。
这样能够避免“一个工具类看懂了,但不知道它为什么被调用”的问题。
十二、这套架构中最容易混淆的几个问题
12.1 为什么不能只靠系统提示词限制模型?
系统提示词会影响模型回答,但它不是强制业务规则。紧急医疗场景、真实业务权限和固定错误文案需要由后端确定性代码兜底。
12.2 为什么路由要发生在读取模型配置之前?
固定回复不需要模型。如果先读取模型、构建 RAG 或启动流式调用,不仅浪费资源,还可能在高风险场景中错误进入自由生成。
12.3 为什么固定回复也要保存到消息记录?
它是系统给用户的正式回答。只发送 SSE 而不入库,会导致刷新后历史消息缺失,多轮上下文也不完整。
12.4 为什么建立连接后还必须发送 done 并 complete?
content 只代表收到内容片段;done 告诉前端业务流结束;complete 关闭服务端连接。缺少后两步可能导致页面持续等待或连接泄漏。
12.5 为什么知识库不能查询我的订单和物流?
知识库保存的是相对稳定的文档知识,个人订单和物流是实时且受权限保护的业务数据。两者必须走不同数据源和安全链路。
总结
本文没有从某个分类器的实现细节开始,而是先建立了整个项目地图。
一次聊天请求的主链路可以概括为:
用户端发送消息
→ ChatController 接收
→ ChatServiceFacade 统一编排
→ ChatContextResolver 构造当前用户的轻量上下文
→ ChatSceneRouter 决定固定回复、澄清、工作流还是继续模型聊天
→ 对应执行服务通过 SSE 返回结果
→ 保存消息
→ 发送完成事件并关闭当前连接
通过“我吃药后呼吸困难,还能继续吃吗?”这个案例,可以看到医疗紧急风险并不会直接交给普通大模型,而是优先形成 MEDICAL_EMERGENCY + CRITICAL + DIRECT_REPLY 的统一路由结果,再由固定回复链路完成发送、入库和关闭。
这也是我阅读真实 AI 项目后最重要的认识:大模型只是执行能力之一,身份、上下文、规则、路由、业务权限、持久化和通信协议共同决定了系统能不能真正用于企业场景。
当前进度
[OK] 已经完成
- 已核对三个仓库在 wuqi 分支上的当前代码基线;
- 已梳理从用户端、Controller、Facade、上下文、路由到 SSE 的完整主链路;
- 已接入医疗风险、客服范围、上下文指代和范围外连续追问等路由能力;
- 已实现固定回复的 SSE 发送、助手消息保存、完成事件和异常关闭;
- 已接入订单查询契约、订单模块和客服主流程;
- 本次 tst-pharma-chat 与 tst-pharma-order 的 230 个相关单元测试全部通过。
[TODO] 后续继续
- 使用当前真实数据库完成订单查询端到端联调;
- 将物流、处方和售后查询接入聊天主流程;
- 针对真实数据继续验证用户归属校验和异常路径;
- 持续补充正式知识库内容并验证召回质量;
- 在实际并发环境中继续观察 SSE 多窗口连接行为。
[RISK] 需要关注
- 不能把“场景已经识别”误写成“真实业务查询已经完成”;
- 不能因为单元测试通过,就忽略数据库、Redis、向量库、模型服务和业务接口的环境差异;
- 医疗安全规则和固定文案后续仍需要结合业务、药师或合规要求持续复核。
小鱼点睛
阅读企业级 AI 项目时,先问“这次请求由谁决策、由谁执行、失败后由谁收口”,比先研究模型参数更重要。模型负责生成,系统负责让生成发生在正确的位置。
下一篇
到这里,我们已经知道一次聊天请求会先经过上下文解析和场景路由,再决定是否调用模型。但这条链路仍留下一个最关键的问题:
既然大模型本身能够理解自然语言,为什么不能把用户问题直接交给大模型,再依靠系统提示词要求它注意安全?
下一篇将结合当前真实代码继续分析:
《企业级医药 AI 智能客服项目实战(二):为什么不能把用户问题直接交给大模型?》

