☰
基于SpringBoot+Vue的AI健康问答系统实战:从数据管理到智能分析
2026/10/21 4:05:11 网站建设 项目流程

最近我帮一位做健康管理的朋友做了一个基于SpringBoot+Vue的智能AI问答个人健康管理系统,断断续续折腾了两三个月。原先他们内部用的是Excel表格记录用户体检数据,咨询顾问每天要翻一堆历史记录才能回答用户问题,效率低且容易漏掉关键指标。做到最后这个系统不但能把健康档案结构化存起来,还能通过AI问答直接解析用户的自然语言提问,比如“我最近三个月血压趋势怎么样”这种问题,后端会自动结合用户档案和时序数据返回结果。整个项目涉及的东西比较杂,从前端交互到后端鉴权再到AI接口对接都有,我觉得把其中的设计思路和踩坑经验整理出来,对正在做类似个人健康类、垂直领域问答系统的朋友应该会有帮助。

这个系统适合谁参考呢?一种是已经在做健康管理、体检中心内部系统的开发者,另一种是想把大模型API整合进SpringBoot业务系统的同学。它不像通用聊天机器人那么复杂,但恰恰因为面向健康数据,细节要求更高,比如权限隔离、数据准确性、流式返回的体验,这些都要认真处理。往下我会先聊聊为什么选了这套技术栈,然后按后端、前端、部署、问题排查的顺序展开,尽量把核心代码和取舍逻辑都讲清楚。

1. 项目整体设计与技术选型拆解

1.1 系统核心需求定位

做之前我先把需求理了一遍,这个系统不是从零发明概念,而是把健康管理业务里最常做的事情搬到线上:用户注册登录、维护个人健康档案(年龄、身高、体重、既往病史、过敏史)、录入周期性的体检数据(血压、血糖、血脂、肝肾功能等)、然后基于这些数据做问答分析。

最开始朋友提需求的时候说得比较模糊,什么“智能分析”“自动建议”都想做。我跟他说,如果一开始就铺太大,后面根本收不住。最后我们把最小可行版本划定成三个核心模块:

  • 用户与家庭成员的档案管理,支持多人数据隔离;
  • 体检指标录入和曲线趋势查看,支持按日期范围筛选;
  • AI问答模块,用户输入自然语言,系统解析意图,从数据库检索对应指标,再调用大模型生成可读性强的分析结论。

整个系统围绕这三条主线展开。AI问答不是上来就开放任意话题,而是限定在健康档案和体检指标范围内,这样既能控制大模型输出的风险,也能保证业务上回答的是真实数据,而不是模型瞎编的内容。

1.2 为什么选SpringBoot+Vue这套组合

技术选型当时不是头脑发热,而是基于朋友团队的真实情况选的。他们团队后端有Java基础,前端之前用过Vue2写过管理后台,生产环境服务器就一台4核8G的云主机,预算有限、运维能力一般。SpringBoot在这类场景下优势很明显:

  • 生态成熟,整合MyBatis-Plus、Spring Security、Redis都很快;
  • 部署简单,打一个Jar包直接丢服务器上跑,不需要像Spring Cloud那样搞一堆微服务组件;
  • 招人容易,Java后端在国内普及率太高了,后面要接手的人不会断层。

前端用Vue3+Vite+Element Plus,主要是因为Vue3已经是主流,Vite的冷启动速度比Webpack快非常多,开发期体验很舒服。Element Plus的表格、表单、日期选择器、弹窗组件都比较完善,非常适合做管理后台。聊天界面我用的是自绘的气泡样式,配合markdown-it渲染富文本,整体效果不输专门套件。

这里我想多说一句,技术选型不要盲目追新。比如有人建议我们用微服务拆分、用Kafka做消息队列,这在我们这个规模的项目里完全用不上。单体应用、单数据库、单服务器,反而更容易运维,出了问题也最好排查。这个判断我认为对类似中小型健康管理项目同样适用。

1.3 AI接口选择的思路

AI问答模块是整个系统里最吸引眼球的部分,也是坑最多的地方。第一版直接调了大模型的在线API,原因是自建模型需要GPU资源,成本和运维压力都不小。在线API的好处是开箱即用,按量付费,不占用本地服务器资源。

为了解决数据时效性问题,我们做了两件事。一是让AI只负责“组织语言和分析表达”,真正查询数据库、取指标数值、做趋势判断这些逻辑由后端程序完成,相当于用代码控制住AI的“输入边界”。二是设计了System Prompt,明确告诉模型只能根据传入的体检数据回答,不要自作主张补充用户没提到过的信息。

你是一个健康数据分析助手。你的任务是基于用户提供的体检指标记录,帮助用户理解指标变化趋势和提出就医建议。 要求: 1. 只依据给定数据回答,不得编造指标数值; 2. 若数据不足以回答,明确告知用户缺少哪些信息; 3. 糖尿病、高血压等疾病的诊断结论必须引导用户前往医疗机构。

这套限制下来,AI幻觉问题虽然不能完全杜绝,但基本控制在了可接受范围内。其实做垂直问答系统的核心,不是把问题丢给大模型就完事,而是怎么让模型在受限的上下文里发挥语言优势。

2. 后端架构与数据库设计

2.1 系统分层与模块划分

后端我采用的是经典的多模块分层结构,但也没有强行拆成多Maven模块,而是用单模块加包名区分来保持简单。整体分这几层:

  • Controller层:接收HTTP请求,参数校验,返回统一结果;
  • Service层:业务逻辑,事务管理;
  • Mapper层:基于MyBatis-Plus操作数据库;
  • 外部接口层:封装调用大模型API的客户端;
  • 工具层:JWT工具、AI提示词构建、日期处理等。
com.example.health ├── controller │ ├── AuthController.java │ ├── HealthRecordController.java │ └── AIChatController.java ├── service │ ├── UserService.java │ ├── HealthRecordService.java │ ├── AIChatService.java │ └── intent │ ├── IntentParser.java │ └── QueryBuilder.java ├── mapper ├── entity ├── dto ├── config ├── utils └── client └── LLMClient.java

这样的结构对后续扩展比较友好。比如以后如果要在AI模块里加入语音输入,只需在intent包下面新增一个处理器,不用改动其他模块。

2.2 核心数据表设计

数据库我用的MySQL 8.0,一共设计了六张核心表。设计时我特别关注了两点:健康数据的复杂性和查询效率。

用户表结构比较简单,重点在角色字段和状态字段:

CREATE TABLE `user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` VARCHAR(100) NOT NULL, `nickname` VARCHAR(50), `role` TINYINT DEFAULT 1 COMMENT '1-普通用户 2-管理员', `status` TINYINT DEFAULT 1 COMMENT '1-正常 0-禁用', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

健康档案表采用与用户一对一关系,把性别、出生日期、身高、体重、血型、过敏史、既往病史这些相对固定的信息放进一张表:

CREATE TABLE `health_profile` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `gender` TINYINT, `birth_date` DATE, `height_cm` DECIMAL(5,1), `weight_kg` DECIMAL(5,1), `blood_type` VARCHAR(5), `allergy_history` VARCHAR(255), `past_history` VARCHAR(500), `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY `uk_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

体检记录表是最核心的业务表。考虑到不同指标类型有不同的单位量级,我没有为每个指标建独立字段,而是采用“一行一条指标”的纵表结构。这样做插入灵活,查询稍复杂,但配合索引可以接受:

CREATE TABLE `health_record` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `record_date` DATE NOT NULL COMMENT '体检日期', `indicator_code` VARCHAR(30) NOT NULL COMMENT '指标编码', `indicator_name` VARCHAR(30) NOT NULL COMMENT '指标名称', `indicator_value` DECIMAL(10,2) NOT NULL, `unit` VARCHAR(20), `ref_range` VARCHAR(50) COMMENT '参考范围', `note` VARCHAR(255), `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, KEY `idx_user_date` (`user_id`, `record_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里用indicator_code而不是直接用中文列名,是为了查询和统计时避免中文匹配带来的性能损耗和编码隐患。系统中预置了血压、空腹血糖、糖化血红蛋白、总胆固醇、甘油三酯、低密度脂蛋白等常用指标字典,每种指标都有标准的参考范围。这个参考范围基于通用医学标准设定,但必须说明系统不做医疗诊断,只做数据展示和趋势分析。

AI问答记录表用来留存用户提问、系统解析结果和原始回复。这个表非常关键,一是便于后续排查AI返回异常,二是积累语料可以优化Prompt:

CREATE TABLE `ai_chat_log` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `question` TEXT NOT NULL, `intent` VARCHAR(50), `status` TINYINT DEFAULT 1, `answer` TEXT, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

设计表的时候我反复提醒自己:不要过度设计。比如要不要搞时序数据库、要不要加Elasticsearch做全文检索,在现阶段都没有必要。单表量级在十万以内时,MySQL配合组合索引足够。真到数据量大了,再做归档分表也不迟。

2.3 项目初始化要做的几件事

初始化项目时,有几处需要特别注意。我建工程用的是Spring Initializr,选择的依赖包括Spring Web、MySQL Driver、MyBatis Plus、Lombok、Validation。密码加密使用BCrypt,没有用Spring Security全家桶,而是手写了一个JWT拦截器,原因是我们只涉及登录鉴权,不需要那么多安全配置,手写更直观。

核心配置文件application.yml里重点配置了数据源和MyBatis Plus的逻辑删除、时间戳自动填充:

spring: datasource: url: jdbc:mysql://localhost:3306/health_ai?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai username: root password: xxxxxx driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0

字段自动填充我是通过自定义MetaObjectHandler实现的,创建时间、更新时间都不用手动赋值。这里有个细节:逻辑删除字段名要和实体类里定义的@TableLogic注解对应,否则MyBatis-Plus不会自动拼接删除条件,容易把历史数据物理删掉。

3. 核心功能模块实现细节

3.1 登录鉴权与用户安全

登录流程用的JWT方案:用户提交用户名密码,后端校验通过后生成Token返回前端。Token里只放用户ID、角色这些非敏感信息,过期时间设为2小时,前端每次请求在Header里带着Authorization: Bearer xxx。

写拦截器的时候,我一开始踩了一个低级坑。拦截器只拦截了/api/**路径,但放行了登录接口后忘记考虑异常拦截顺序,导致未登录请求直接进入Controller,空指针异常满天飞。后来统一做了处理:自定义拦截器里校验Token,失败直接返回401状态码,配合全局异常处理器返回统一JSON结构。

@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getMethod().equals("OPTIONS")) { return true; } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new BusinessException(401, "未登录或Token已过期"); } Long userId = JwtUtil.parseToken(token.replace("Bearer ", "")); UserContext.set(userId); return true; } }

这里用了一个ThreadLocal来做用户上下文传递,在Service层可以随时取到当前登录用户ID,不用每个方法都传参。但千万注意,接口结束后要释放ThreadLocal,否则线程池复用时会串号。我在WebMvcConfig里注册拦截器的同时也注册了一个afterCompletion方法清理线程变量。

健康数据的权限隔离是比较关键的点。查询体检记录时,SQL必须带上user_id = 当前用户ID条件,不能只靠前端传参。防越权的思路很简单:后端永远从Token解析出用户身份,而不是信任前端传过来的任何userId字段。这个后来在测试阶段被我重点验证了,用A用户Token去查B用户数据,返回的必须是空或403。

3.2 健康档案与体检数据管理

健康档案的接口做得比较常规,主要涉及增删改查和文件导入。由于数据规模不大,Excel导入功能用的是EasyExcel,支持上传标准格式的体检报告模板,解析后批量写入数据库。这里有两个容易忽略的点:

第一,Excel模板的列顺序要和后端解析模型保持一致。我在模板第一行加了注释,同时后端也做了字段名校验。一旦发现某列数据格式异常,会返回具体是第几行哪个字段出了问题,而不是整批失败让用户一头雾水。

第二,纵表结构的写入需要做幂等处理。比如用户重复上传同一日期的体检报告,不能插入重复记录。我的方案是组合唯一索引(user_id, record_date, indicator_code),插入前先按这三个字段查询,存在则更新值,不存在则新增。

查询体检趋势的接口专门做了一个聚合查询,支持传入日期范围、指标编码列表,返回结构包含日期数组和值数组,方便前端直接画折线图:

public List<TrendPoint> getTrend(Long userId, List<String> indicators, LocalDate start, LocalDate end) { LambdaQueryWrapper<HealthRecord> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(HealthRecord::getUserId, userId) .in(HealthRecord::getIndicatorCode, indicators) .between(HealthRecord::getRecordDate, start, end) .orderByAsc(HealthRecord::getRecordDate); // 按 indicator_code + record_date 分组聚合为 Point }

到了这一步,整个数据基础就打好了。AI问答本质上是把这个查询能力用自然语言包装了一层,让用户不用自己选择指标和日期区间。

3.3 AI问答模块:意图解析与数据检索

AI问答模块是整个系统开发耗时最长、试错成本最高的地方。我最初想得很简单,用户提问直接拼接Prompt丢给大模型,让它返回结论。结果测试时发现,模型经常把用户最近的体检数值记错,甚至凭空捏造一个指标出来。后来我彻底改变了思路。

第一步是先做意图识别。后端收到用户问题后,先通过一个轻量的规则加正则的方式判断问题类型。判断维度包括:

  • 是否包含趋势类词汇(趋势、变化、对比、最近);
  • 是否包含具体指标名(血糖、血压、胆固醇);
  • 是否包含时间范围(最近三个月、上半年、2023年);
  • 是否包含对比对象(对比上次、和上个月比)。

用这些信号组装出一个结构化查询对象IntentQuery,里面包含指标编码、时间范围、聚合方式。这里我刻意没有用NLP模型做意图分类,原因很简单:健康领域问题句式比较固定,正则和词表在90%的场景下够用,而且可解释、可调试。用户问“我最近三个月血糖怎么样”,会被解析为:

indicatorCodes = ["GLU"] startDate = 2025-01-01 endDate = 2025-03-30 queryType = TREND_SUMMARY

第二步是拿这个结构化查询条件去数据库取数。取到的数据是一个个指标记录的列表,包含日期、数值、单位、参考范围。把这些数据转成紧凑的文本描述,比如:

血压(收缩压)指标记录: 2025-01-15:128mmHg(参考范围90-140) 2025-02-18:135mmHg(参考范围90-140) 2025-03-20:142mmHg(参考范围90-140) 注意:3月20日超出参考范围。

然后把这段文本连同用户原问题一起作为Prompt发给大模型。模型的任务不是检索,而是基于给定的数据做归纳和表达。这样做的结果是,哪怕模型有时候措辞不够稳妥,至少数字都是真实库里的,不会出现一本正经地胡说八道。

第三步是流式输出。大模型接口支持SSE流式返回,前端用EventSource或者fetch流式读取,逐字显示在聊天窗口中。体验比等全部生成完再一次性返回好很多,用户等待的心理时长大大缩短。后端实现时用了Spring的SseEmitter,但没有用一个请求直接挂到大模型返回完成,而是做了一层缓冲队列,配合异步线程池调用外部API。这块前面几版老是把HTTP连接池占满,后来设置了合理的超时时间并把连接池大小调大,问题才缓解。

@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chatStream(@RequestParam String question) { SseEmitter emitter = new SseEmitter(60_000L); aiChatService.asyncAnswer(question, emitter); return emitter; }

这里要提醒做类似项目的朋友:SSE的默认超时时间必须显式设置,否则前端可能收到连接意外关闭。我第一版没设置超时参数,nginx默认60秒无响应就断开,导致用户问一个长问题时页面直接报错。这个坑后面我在排查章节再细说。

Prompt的构造也是重头戏。我后来总结了一套比较有效的模板结构:角色定义、数据来源说明、回答约束、输出格式、待分析数据。为了不让Token白烧钱,还在Prompt里限制输出长度,比如“回答控制在200字以内,分点说明”。最终效果在测试集上,回答的专业性和可读性都有了明显提升。

4. 前端Vue3实现与交互体验优化

4.1 工程搭建与页面结构

前端工程使用Vite+Vue3+Element Plus搭建,Pinia做全局状态管理。之所以没选Vue2,是因为Vue3的Composition API在逻辑复用上写起来确实比Options API顺手。项目页面主要分三块:登录注册页、健康管理主页面、AI问答对话页。

主页面布局采用经典的侧边栏加顶栏结构,侧边栏菜单包括工作台、健康档案、体检记录、指标趋势、AI助手。路由做了守卫:没登录访问任意页面都重定向到登录页。

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.path !== '/login' && !token) { next('/login'); } else { next(); } });

这里有个细节:Token存储我用的localStorage,但官方其实更推荐httpOnly的Cookie以降低XSS风险。考虑到我们是内部系统且HTTPS环境部署,localStorage够用。如果是公网SaaS产品,建议还是用Cookie方案。

健康档案表单包括基础信息、既往史、过敏史等,提交前做了前端基础校验。体检记录页面支持按时间范围筛选、按指标类型筛选,表格展示最近体检记录。趋势图部分用的是Apache ECharts折线图,Y轴单位根据指标动态切换。

4.2 AI问答界面实现细节

聊天页面是这次开发的重点之一。界面分为左侧会话列表、中间聊天窗口、底部输入框三块。聊天消息区用flex布局实现上下滚动,气泡用不同背景色区分用户和助手消息。助手回答支持Markdown渲染,当后端返回的文本包含列表、加粗等格式时,会先通过markdown-it转成HTML再渲染,代码块则用highlight.js高亮。

流式接收的实现,我踩过版本兼容的坑。最初用的EventSource只能接收GET请求,但我们的流式接口是GET,所以没问题。如果后续改成POST请求,就得用fetch的ReadableStream自己解析了。前端核心代码如下:

async function sendMessage() { const response = await fetch(`/api/ai/chat/stream?question=${encodeURIComponent(input.value)}`, { headers: { 'Authorization': `Bearer ${token}` } }); const reader = response.body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; const text = decoder.decode(value); currentAnswer += text.replace(/^data:/, '').trim(); // 将 currentAnswer 增量渲染到消息气泡 } }

这里有个用户体验优化点:接收流式数据时进度提示放在消息气泡下方,用三个点循环跳动动画表示“正在生成中”。同时在输入框设置了防抖,防止用户连续点击发送导致重复请求。

图表联动也是值得一提的设计。当用户在AI问答里问“我最近半年血糖变化”时,回答文本的下方会动态渲染一个折线图组件,把后端返回的趋势数据可视化呈现。这个功能是通过后端在流式返回的最后追加一段JSON数据块实现的,前端解析到data: [chart]前缀时,就把后续内容解析为图表配置并渲染。这样既不影响文本流畅度,又让数据展示更有说服力。

4.3 状态管理与权限控制

Pinia里我维护了三个核心store:用户信息、会话状态、健康数据缓存。用户信息在登录成功后写入,包括昵称、角色、Token。健康数据缓存主要用于体检记录页,避免每次切Tab都重新请求接口。

// stores/user.ts export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('token') || '', userInfo: null }), actions: { setToken(token: string) { ... } } });

前端权限控制做得很简单:菜单根据用户角色的isAdmin字段判断是否显示管理入口。真正的权限控制全靠后端接口做,前端只是隐藏入口,不能当成安全边界。

5. 部署上线与实测分析

5.1 打包与部署步骤

部署过程我用了一份比较直接的方案:后端Maven打包成Jar,前端Vite构建生成dist静态文件,由Nginx托管;Nginx配置反向代理,把/api路径转发到后端服务端口。

关键步骤记录一下:

  • 后端打包:mvn clean package -DskipTests,生成health-system.jar;
  • 前端构建:npm run build,产物在dist目录;
  • 服务器环境:安装JDK17、MySQL8、Nginx;
  • 后端启动:用systemd服务托管,设置ExecStart=/usr/bin/java -jar /opt/health-system.jar --spring.profiles.active=prod;
  • Nginx配置:root指向dist目录,location /api { proxy_pass http://127.0.0.1:8080; }。

Nginx配置里有一个必须注意的选项:SSE流式接口需要关闭proxy缓冲,否则Nginx会把数据攒起来一次性发给前端,流式效果就没了。对应配置是:

location /api/ai/chat/stream { proxy_pass http://127.0.0.1:8080; proxy_buffering off; proxy_cache off; proxy_read_timeout 120s; }

这个配置当时找了很多资料才确认。默认情况下proxy_buffering是开启的,流式接口响应会被缓冲,前端要等整个回答生成完才收到全部数据,等于流式白做了。

5.2 数据迁移与初始化

上线前还有一个重要环节是历史数据迁移。朋友那有几百条Excel记录的存量体检数据,我写了一个一次性迁移脚本,用EasyExcel读取后分批插入数据库,每500条提交一次事务。迁移过程中发现一些脏数据,比如某个用户的身高字段是“170cm”字符串,指标值里有空格和中文单位混用。清洗逻辑是在脚本里做的:按正则抽取数字部分,单位统一映射为标准单位。

数据库初始化我用了Spring的schema.sql自动建表,同时准备了data.sql插入指标字典和默认管理员账号。生产环境开启了spring.sql.init.mode=never,避免误执行初始化脚本覆盖线上数据。这个开关在开发环境是always,测试环境是embedded,生产环境必须手动执行一次后关闭。

5.3 性能测试与资源占用

系统上线后,我做了一轮基础性能验证。压测工具用的JMeter,模拟50个并发用户同时操作体检记录查询和AI问答。核心数据如下:

场景并发数平均响应时间失败率
登录接口50180ms0%
体检记录分页查询50260ms0%
趋势查询(3指标/1年)50420ms0%
AI问答(短文本)204.2s0%
AI问答(带趋势图)108.5s0%

AI问答接口响应时间主要取决于大模型API的生成速度,本地瓶颈不大。但在压测时我发现一个隐患:每轮问答的日志写入都在主线程里同步执行,会让接口响应增加几十毫秒。后来改成异步保存聊天记录,用一个@Async方法处理,主流程只负责返回结果,性能明显改善。

服务器方面,4核8G机器在运行MySQL、Java应用和Nginx的情况下,内存占用约4.5G,CPU峰值在40%左右,整体有富余。AI问答时的网络IO和JSON解析是主要CPU消耗点。如果后续用户量增长,可以先把MySQL和Java应用放到两台机器上,再把Nginx前置做负载均衡。

6. 实战中遇到的问题与排查心得

6.1 越权漏洞与ThreadLocal串数据

开发到中期,我做了一次自测,用A账号登录后,直接改前端请求里的userId参数去查询B账号的体检数据。果然,接口返回了B的数据。原因就是查询时直接用了前端传的userId,没有从Token里解析。发现问题后我把所有涉及用户数据的查询方法统一改成:从UserContext.getCurrentUserId()取值,方法参数列表里彻底移除userId字段。同时做了一个数据库层面的兜底:多租户插件直接按上下文字段自动拼接SQL条件,想传错都难。

另一个印象深刻的问题是ThreadLocal串号。因为JWT拦截器里把用户ID放进了ThreadLocal,但没在请求结束后清理。Tomcat的线程池会复用线程,导致下一次请求拿到上一个用户的ID,出现数据错乱。这是生产环境事故级别的bug。排查时是通过日志发现,同一个请求线程打印的用户ID一会是A一会是B。解决方案是在拦截器的afterCompletion方法里调用UserContext.clear(),彻底清空线程变量。

6.2 AI流式接口的Nginx缓冲问题

这个问题前端现象和排查过程都比较典型。刚开始对接AI接口时,前端页面要等十几秒才一次性蹦出整个回答,而不是逐字逐句地显示。一开始我以为是后端SseEmitter写数据有问题,用Postman直接调后端接口发现流式输出正常。怀疑对象转向Nginx,最后确认就是proxy_buffering默认开启导致的。

解决方式就是前面提到的关闭代理缓冲。这里还需要注意,浏览器端如果用的是EventSource,它原生只支持GET请求。如果你要传较长的用户问题参数,超过URL长度限制时就得改成POST+fetch流式读取方案。我是在问题长度测试中发现的:用户粘贴一段很长的体检报告描述,URL传参被截断了。后面改为POST请求,并把参数放到请求体里。

6.3 大模型输出的数据不准确

上线测试期间,有用户反馈AI回答里的数值跟体检报告对不上。仔细排查后发现问题出在Prompt构造阶段:原始数据里的指标值没有带单位直接拼进了文本,模型在组织语言时自己补了单位。比如原始数据是数值6.2,模型写成了“6.2mg/L”,但用户报告实际单位是mmol/L。这其实是我在数据预处理时的疏漏。

我的修复方式是:数据文本里明确写出“单位:mmol/L”这样完整的结构,不让模型有机会自行猜测。同时Prompt里专门加了一句“引用数据时必须使用原始数据的单位和参考范围,不得改写”。经过了这轮调整后,再没出现过单位漂移的问题。

另一个常见情况是用户问“我这个指标算不算正常”,模型有时会给出过于绝对的结论。解决办法是在Prompt里限定回答边界,只允许说“从数据看,该指标在参考范围内/外”,凡涉及诊断性质的问题,强制引导就医。系统里还加了敏感词拦截,如果用户问题里出现“确诊”“开药”等关键词,AI会直接返回预设的安全提示,不再调用大模型。

6.4 聊天记录存储的异步缺陷

最初聊天记录保存是同步的,每轮AI问答结束后往ai_chat_log表插入一条记录。压测时发现,如果回答文字很长,插入耗时可以达到100毫秒以上,拖慢响应。后来改成用Spring的@Async配合独立线程池执行保存操作,但这里有个新坑:因为主线程返回后,异步线程可能还没执行完,如果同时有大量请求,线程池队列会积压。观察后发现我们这个场景每秒最多十几条消息,默认线程池完全撑得住,所以没有过度设计。

如果后续真的需要保证聊天记录不丢,可以考虑引入消息队列,但现阶段加MQ反而是负担。

6.5 前端长列表渲染卡顿

聊天记录如果一直不清理,渲染几百条消息时页面开始卡顿。原因是一次性把几百个DOM节点全部渲染,同时每个消息都要做Markdown解析。我的优化方案是分页加载:滚动到接近顶部时加载更早的历史消息,每次只渲染最近50条。另一个优化点是,当消息是纯文本且没有任何Markdown语法时,直接跳过markdown-it解析,用普通文本渲染,性能提升明显。

这里也建议,如果用户量变大,聊天记录可以考虑用虚拟滚动组件(比如vue-virtual-scroller),但需要评估组件引入成本。

写在最后

这个系统做下来,我个人最大的体会是:AI问答类项目,真正的难点不在“接一个大模型API”,而在如何把模型能力嵌入到业务数据的准确查询和领域约束里。纯靠Prompt硬问,效果是不稳定的。我现在这个版本,本质上是用结构化查询结果喂给模型做润色,AI更像一个数据分析报告生成器,而不是知识库检索器。这也更符合健康管理这种需要高准确度场景的定位。

如果你也在做类似的垂直领域问答系统,我建议从最小闭环开始:先把手动能查到的数据通过规则方式查出来,再逐步用AI增强表达能力,不要一开始就指望模型理解整站数据。另外多留日志,多记录用户的提问和系统的意图解析结果,这些数据后面越攒越有价值。如果后续要扩展,可以在这个基础上加入指标异常检测的规则引擎,或者对接可穿戴设备的实时数据,整个系统还能继续长出新功能来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询