Skip to content

项目复盘:心理健康 AI 助手 ​

相关笔记:04 框架技术栈 · 8. Spring AI、JUC 并发编程

项目技术栈:Spring Boot 3 + MyBatis-Plus + Spring Security + JWT + SSE + Spring AI + MySQL + Redis 核心功能:流式 AI 对话、心理测评、咨询预约、情绪日记 我的角色:独立开发(后端 + 数据库设计 + 部署) 规模:单人 3 个月,日均对话(按你的实测填),P95 首字延迟(按你的实测填)

说明:这篇是给面试用的复盘,所以除了「做了什么」,更强调「为什么这么做、踩了什么坑、怎么验证有效」。数字请替换成你自己的实测值。


一、项目概览 ​

1.1 要解决的问题 ​

心理咨询资源稀缺、价格高、用户有病耻感,很多人不愿意第一时间找人聊。产品想做的是一层「低门槛的陪伴入口」:先由 AI 做情绪疏解与初步筛查,风险高的用户引导到真人咨询师。

这决定了两条设计红线:

  1. 不能乱说:涉及自伤、自杀倾向必须走固定话术并给出求助热线,不能让模型自由发挥。
  2. 隐私极敏感:聊天内容是最高敏感数据,日志、缓存、第三方调用都要考虑脱敏。

1.2 功能边界 ​

模块做什么技术要点
AI 对话流式聊天、多轮上下文SSE + Spring AI + token 预算
心理测评量表答题、自动计分、结论规则引擎(不做成 AI,结果要可复现)
咨询预约咨询师排班、下单、状态机幂等 + 分布式锁防重复预约
情绪日记记录 + 趋势分析定时聚合 + 图表接口
风险识别关键词 + 模型双通道命中即转人工、留痕审计

关键取舍:测评不用 AI 判分。量表计分必须确定、可追溯、能出报告,用规则引擎;AI 只负责把结论「翻译成人话」。


二、整体架构 ​

微信小程序
    │  HTTPS
    ▼
Nginx(SSE 专用 location:proxy_buffering off)
    │
    ▼
Spring Boot 应用
    ├── Security + JWT 过滤器(支持 header 与 query 双通道)
    ├── ChatController ──► ChatService
    │        ├── 会话/消息持久化(MySQL)
    │        ├── 上下文装配(最近 N 轮 + 摘要 + 用户画像)
    │        ├── 风险预检(关键词 + 小模型分类)
    │        ├── Spring AI ChatClient ──► 模型网关(硅基流动 / OpenAI 兼容)
    │        └── SseEmitter 逐块推送
    ├── 预约服务(Redisson 分布式锁 + 幂等表)
    └── 定时任务(情绪趋势聚合、会话摘要压缩)
    │
    ├── MySQL(业务数据)
    └── Redis(会话缓存、限流、锁)
微信小程序
    │  HTTPS
    ▼
Nginx(SSE 专用 location:proxy_buffering off)
    │
    ▼
Spring Boot 应用
    ├── Security + JWT 过滤器(支持 header 与 query 双通道)
    ├── ChatController ──► ChatService
    │        ├── 会话/消息持久化(MySQL)
    │        ├── 上下文装配(最近 N 轮 + 摘要 + 用户画像)
    │        ├── 风险预检(关键词 + 小模型分类)
    │        ├── Spring AI ChatClient ──► 模型网关(硅基流动 / OpenAI 兼容)
    │        └── SseEmitter 逐块推送
    ├── 预约服务(Redisson 分布式锁 + 幂等表)
    └── 定时任务(情绪趋势聚合、会话摘要压缩)
    │
    ├── MySQL(业务数据)
    └── Redis(会话缓存、限流、锁)

分层原则:Controller 只做协议(SSE / 鉴权),Service 编排,AI 调用收敛到单独的 gateway 层 —— 这样换模型、加熔断只改一处。


三、数据模型 ​

3.1 会话与消息(一对多) ​

conversation(会话表)
  ├── id
  ├── user_id          → 用户
  ├── title            → 会话标题(首条消息截断生成)
  ├── summary          → 早期对话的压缩摘要
  ├── status           → 进行中 / 已结束
  ├── last_message_at  → 列表排序用
  └── created_at

message(消息表)
  ├── id
  ├── conversation_id  → 所属会话
  ├── role             → user / assistant / system
  ├── content          → 消息内容
  ├── token_count      → 消耗 token 数
  ├── risk_level       → none / low / high(风险识别结果)
  └── created_at
conversation(会话表)
  ├── id
  ├── user_id          → 用户
  ├── title            → 会话标题(首条消息截断生成)
  ├── summary          → 早期对话的压缩摘要
  ├── status           → 进行中 / 已结束
  ├── last_message_at  → 列表排序用
  └── created_at

message(消息表)
  ├── id
  ├── conversation_id  → 所属会话
  ├── role             → user / assistant / system
  ├── content          → 消息内容
  ├── token_count      → 消耗 token 数
  ├── risk_level       → none / low / high(风险识别结果)
  └── created_at
sql
CREATE TABLE message (
  id              BIGINT PRIMARY KEY AUTO_INCREMENT,
  conversation_id BIGINT      NOT NULL,
  role            VARCHAR(16) NOT NULL,
  content         TEXT        NOT NULL,
  token_count     INT         DEFAULT 0,
  risk_level      VARCHAR(16) DEFAULT 'none',
  created_at      DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  KEY idx_conv_time (conversation_id, created_at)   -- 取最近 N 条全靠它
);
CREATE TABLE message (
  id              BIGINT PRIMARY KEY AUTO_INCREMENT,
  conversation_id BIGINT      NOT NULL,
  role            VARCHAR(16) NOT NULL,
  content         TEXT        NOT NULL,
  token_count     INT         DEFAULT 0,
  risk_level      VARCHAR(16) DEFAULT 'none',
  created_at      DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  KEY idx_conv_time (conversation_id, created_at)   -- 取最近 N 条全靠它
);

设计要点:

  • 会话与消息一对多,消息按 created_at 排序,索引必须是 (conversation_id, created_at) 复合,否则「取最近 10 轮」会回表扫全表。
  • 每次对话前取最近 N 条作为上下文;token_count 用于预算控制与用量统计。
  • 加 summary 字段而不是另建表:摘要一条会话只有一份,跟随会话生命周期。
  • risk_level 落库:既是审计要求,也能做风险事件的统计复盘。

3.2 其他业务表 ​

表名说明
user用户(用户名、密码 BCrypt、角色、状态)
assessment心理测评记录(量表类型、原始答案 JSON、得分、结论)
appointment咨询预约(咨询师、时段、状态机:待支付/已确认/已完成/已取消)
mood_diary情绪日记(日期、情绪类型、内容、AI 分析结果)
idempotent_record幂等表(业务 key + 结果,防重复提交/重复消费)
ai_usage用量账单(用户、模型、输入/输出 token、耗时、是否降级)

ai_usage 是容易被忽略但面试很加分的表:没有它就无法回答「这个月 AI 花了多少钱、哪个用户用得最多」。


四、核心链路 1:SSE + Spring AI 流式对话 ​

4.1 时序 ​

前端 EventSource
  │ GET /api/chat/stream?message=xxx(token 走 query 或先换一次性 ticket)
  ▼
ChatController
  ├─ 1. 校验 JWT → userId
  ├─ 2. 取/建 Conversation
  ├─ 3. 风险预检(关键词 + 分类),高危直接走固定话术,不调模型
  ├─ 4. 保存用户消息(异步)
  ├─ 5. 装配上下文:摘要 + 最近 N 轮 + RAG 片段
  ├─ 6. token 预算检查,超限先砍历史
  ├─ 7. 调 Spring AI,拿 Flux<String>
  ├─ 8. 逐块写 SseEmitter(带心跳)
  ├─ 9. 结束后拼接全文 → 存 assistant 消息 → 扣额度 → 记 ai_usage
  └─ 10. complete()
前端 EventSource
  │ GET /api/chat/stream?message=xxx(token 走 query 或先换一次性 ticket)
  ▼
ChatController
  ├─ 1. 校验 JWT → userId
  ├─ 2. 取/建 Conversation
  ├─ 3. 风险预检(关键词 + 分类),高危直接走固定话术,不调模型
  ├─ 4. 保存用户消息(异步)
  ├─ 5. 装配上下文:摘要 + 最近 N 轮 + RAG 片段
  ├─ 6. token 预算检查,超限先砍历史
  ├─ 7. 调 Spring AI,拿 Flux<String>
  ├─ 8. 逐块写 SseEmitter(带心跳)
  ├─ 9. 结束后拼接全文 → 存 assistant 消息 → 扣额度 → 记 ai_usage
  └─ 10. complete()

4.2 为什么是 SSE 而不是普通接口 ​

方式体验复杂度
普通接口白屏等 10–30 秒,用户以为卡死低
SSE 流式逐字出现,用户能感知「正在思考」中

大模型的首 token 延迟通常 0.5–2 秒、完整回答要十几秒。同样的总耗时,流式能显著降低用户的中途放弃率 —— 这是选它的核心理由,而不是「技术时髦」。

4.3 代码骨架 ​

java
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@RequestParam String message, @RequestParam String token) {
    Long userId = jwtService.verify(token);           // EventSource 不能带 header,走 query
    SseEmitter emitter = new SseEmitter(120_000L);    // 默认 30s 不够
    emitter.onTimeout(emitter::complete);
    emitter.onError(e -> log.warn("sse error, userId={}", userId, e));

    executor.execute(() -> {                          // 关键:不占用 Tomcat 请求线程
        try {
            String reply = chatService.streamChat(userId, message, chunk -> {
                try { emitter.send(SseEmitter.event().data(chunk)); }
                catch (IOException ignored) { /* 客户端已断开 */ }
            });
            chatService.saveAssistantMessage(userId, reply);
            emitter.complete();
        } catch (Exception e) {
            emitter.completeWithError(e);             // 不要抛给全局异常处理器
        }
    });
    return emitter;
}
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@RequestParam String message, @RequestParam String token) {
    Long userId = jwtService.verify(token);           // EventSource 不能带 header,走 query
    SseEmitter emitter = new SseEmitter(120_000L);    // 默认 30s 不够
    emitter.onTimeout(emitter::complete);
    emitter.onError(e -> log.warn("sse error, userId={}", userId, e));

    executor.execute(() -> {                          // 关键:不占用 Tomcat 请求线程
        try {
            String reply = chatService.streamChat(userId, message, chunk -> {
                try { emitter.send(SseEmitter.event().data(chunk)); }
                catch (IOException ignored) { /* 客户端已断开 */ }
            });
            chatService.saveAssistantMessage(userId, reply);
            emitter.complete();
        } catch (Exception e) {
            emitter.completeWithError(e);             // 不要抛给全局异常处理器
        }
    });
    return emitter;
}

4.4 为什么用 Spring AI 而不是裸调 HTTP ​

  • 多厂商(硅基流动 / OpenAI / 通义 / 本地 Ollama)一套接口,换模型只改配置;
  • Advisor 机制把「记忆 / RAG / 日志」做成可插拔的链,业务代码只写 .prompt().user(q).stream();
  • @Tool 直接映射到 Spring Bean,工具调用不用手写 JSON Schema。

代价是版本迭代快(1.0 前后 artifact 命名都变了),要锁版本。


五、核心链路 2:上下文与记忆管理 ​

这是最容易出问题、也最能体现工程能力的地方。

5.1 token 预算 ​

每次请求先算账,再决定带多少历史:

预算 = 模型上下文上限(如 32k) - 预留输出(如 4k)
开销 = 系统提示词 + 摘要 + RAG 片段 + 用户本次输入 + 历史消息
超出 → 按「先砍最老的历史 → 再砍 RAG 条数 → 最后压缩摘要」的顺序降级
预算 = 模型上下文上限(如 32k) - 预留输出(如 4k)
开销 = 系统提示词 + 摘要 + RAG 片段 + 用户本次输入 + 历史消息
超出 → 按「先砍最老的历史 → 再砍 RAG 条数 → 最后压缩摘要」的顺序降级

5.2 三级记忆 ​

层级内容实现
短期最近 10 轮原始对话按 conversation_id + created_at 倒序取
中期更早对话的摘要超过 10 轮触发异步摘要,写回 conversation.summary
长期用户画像(情绪倾向、关注话题)定时任务聚合,作为系统提示词的一部分

5.3 踩过的坑 ​

  • 早期只做滑动窗口:用户聊到第 30 轮,第 1 轮说的关键信息(比如「我在准备考研」)丢了,回答开始跑偏。→ 加摘要层解决。
  • 摘要没有版本:改 Prompt 后老摘要和新格式混在一起,模型理解错乱。→ 摘要加 summary_version,版本不匹配就重建。
  • 摘要失败没人知道:异步任务异常被吞,会话摘要一直为空。→ 加失败重试 + 监控指标。

六、核心链路 3:RAG 知识库(心理科普 + 咨询师 FAQ) ​

6.1 链路 ​

离线:文档 → 清洗(去页眉页脚)→ 切块(512 token,overlap 64)→ 向量化 → 入 Milvus/ES
在线:问题 → 向量化 → 混合检索(BM25 + 向量,RRF 融合)→ Rerank 取 top5
      → 拼进 Prompt(带编号,要求「只用资料回答并标注来源」)→ 生成
离线:文档 → 清洗(去页眉页脚)→ 切块(512 token,overlap 64)→ 向量化 → 入 Milvus/ES
在线:问题 → 向量化 → 混合检索(BM25 + 向量,RRF 融合)→ Rerank 取 top5
      → 拼进 Prompt(带编号,要求「只用资料回答并标注来源」)→ 生成

6.2 为什么必须做混合检索 ​

纯向量对专有名词很弱:「SCL-90」「认知行为疗法 CBT」这类术语,向量会把语义相近但完全不同的内容召回来。加了 BM25 关键词通道后,术语命中率明显提升。

6.3 怎么验证有效 ​

建了一套 50 条的人工标注集(问题 + 标准答案所在的文档 id),每次改切块/检索参数都跑一遍:

指标改动前改动后
Recall@5(填)(填)
空召回率(填)(填)
答案忠实度(人工抽检)(填)(填)

面试要点:没有评估集的 RAG 优化都是玄学。能说出「我怎么建评估集、看哪个指标」的候选人,可信度立刻不一样。


七、安全与鉴权 ​

7.1 JWT 与 EventSource 的冲突 ​

EventSource 不支持自定义请求头,没法带 Authorization: Bearer xxx。三种解法,我选了第三种:

方案问题
把 token 放 URL querytoken 会进 Nginx access log、浏览器历史,有泄露风险
用 Cookie 承载需要防 CSRF,且小程序侧跨域麻烦
先换一次性 ticket短有效期(30s)+ 一次性,泄露窗口最小 ✅

7.2 越权防护 ​

  • 每次取会话都校验 conversation.user_id == 当前 userId(不能只按 conversationId 查,这是 IDOR 经典漏洞);
  • 消息、测评结果、预约同理;
  • 工具调用(@Tool)内部再校验一次,不信任模型给的参数。

7.3 内容安全 ​

  • 风险预检:关键词(自杀、自伤、伤人)+ 小模型分类双通道,命中即走固定话术 + 热线 + 转人工;
  • 输出过滤:敏感词、医疗诊断类越界表述;
  • 留痕:risk_level 落库 + 全程审计日志,合规要求。

八、性能与容量 ​

关注点措施
SSE 占线程业务处理丢到自定义线程池,Tomcat 请求线程立刻释放
慢在模型首 token 超时 10s、总超时 120s,超时降级到固定话术
突发流量用户级限流(每分钟 N 次)+ 全局并发信号量(Semaphore)
重复提交预约下单用「幂等表 + Redisson 分布式锁」
读多写少测评题目、咨询师排班进 Redis(过期时间见下表)
长尾高频 FAQ 问题做结果缓存(问题归一化后哈希)

8.1 Redis 缓存清单 ​

场景Key过期
心理测评题目assessment:questions:{type}永久(题目不变)
用户会话列表conversation:list:{userId}10 分钟
咨询师排班schedule:{date}1 小时
热点 AI 回复ai:cache:{messageHash}1 小时

8.2 可以异步化的地方 ​

场景方案注意
保存消息@Async 写库用有界队列 + 自定义线程池,别用默认的 SimpleAsyncTaskExecutor
token 统计异步累加,定时批量刷丢一点可接受
情绪日记分析异步调 AI 分析趋势要能重试
java
@Bean("chatAsyncExecutor")
public Executor chatAsyncExecutor() {
    return new ThreadPoolExecutor(4, 16, 60, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(2000),
            new ThreadFactoryBuilder().setNameFormat("chat-async-%d").build(),
            new ThreadPoolExecutor.CallerRunsPolicy());   // 满了让调用方自己跑,天然限速
}
@Bean("chatAsyncExecutor")
public Executor chatAsyncExecutor() {
    return new ThreadPoolExecutor(4, 16, 60, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(2000),
            new ThreadFactoryBuilder().setNameFormat("chat-async-%d").build(),
            new ThreadPoolExecutor.CallerRunsPolicy());   // 满了让调用方自己跑,天然限速
}

为什么要写这么细:@Async 不指定线程池时用的是 SimpleAsyncTaskExecutor,每次调用新建线程,高并发下直接把机器打穿。这是面试官很爱追问的点。

8.3 限流 ​

java
// 每个用户每分钟最多 5 次对话请求(令牌桶,Redis 实现)
if (!rateLimiter.tryAcquire("chat:" + userId, 5, Duration.ofMinutes(1))) {
    throw new BizException("请求太频繁,请稍后再试");
}
// 每个用户每分钟最多 5 次对话请求(令牌桶,Redis 实现)
if (!rateLimiter.tryAcquire("chat:" + userId, 5, Duration.ofMinutes(1))) {
    throw new BizException("请求太频繁,请稍后再试");
}

九、可观测性 ​

维度做法
链路traceId 贯穿「HTTP → RAG 检索 → 模型调用 → 落库」,日志用 MDC
指标首 token 延迟 P95、总耗时、token 消耗、失败率、降级率、空召回率
告警降级率 > 5%、P95 首字延迟 > 3s、模型错误率 > 10%
审计ai_usage 表 + 风险事件表,可按用户/时间聚合

一条日志大概长这样:

traceId=ab12cd chat userId=1001 convId=88 model=qwen2.5-72b
  ragHits=5 emptyRecall=false promptTokens=2380 outTokens=412
  firstTokenMs=760 totalMs=8900 degrade=false
traceId=ab12cd chat userId=1001 convId=88 model=qwen2.5-72b
  ragHits=5 emptyRecall=false promptTokens=2380 outTokens=412
  firstTokenMs=760 totalMs=8900 degrade=false

能拿出这种日志结构,面试时说服力很强 —— 说明你真的在生产环境定位过问题。


十、踩坑清单 ​

10.1 SSE 连接断开重连 ​

现象:前端 EventSource 偶尔断开,对话中断。

原因:SseEmitter 默认超时 30 秒,长回答超过就断。

解决:

java
SseEmitter emitter = new SseEmitter(120000L);
emitter.onTimeout(() -> { log.warn("SSE 超时"); emitter.complete(); });
emitter.onError(e -> log.error("SSE 异常", e));
SseEmitter emitter = new SseEmitter(120000L);
emitter.onTimeout(() -> { log.warn("SSE 超时"); emitter.complete(); });
emitter.onError(e -> log.error("SSE 异常", e));

配套:服务端每 15s 发一次心跳(注释行 : ping),防止中间设备按空闲超时掐连接。

10.2 Nginx 下 SSE 变成一次性返回 ​

现象:本地正常,服务器上打字机效果没了。

原因:Nginx 默认开 proxy_buffering,把后端数据攒起来一次发。

解决:

nginx
location /api/chat/stream {
    proxy_pass http://backend;
    proxy_buffering off;        # 关键
    proxy_cache off;
    proxy_read_timeout 3600s;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
}
location /api/chat/stream {
    proxy_pass http://backend;
    proxy_buffering off;        # 关键
    proxy_cache off;
    proxy_read_timeout 3600s;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
}

10.3 Security + JWT 与 EventSource ​

现象:EventSource 不支持自定义 header,带不了 token。

解决:改为「先换一次性 ticket,再用 ticket 建流」,避免 token 出现在 URL 里被日志记录。

10.4 全局异常吞掉 SSE 流 ​

现象:出错时前端收到 JSON,EventSource 报「响应格式错误」。

原因:@RestControllerAdvice 把 SSE 接口的异常也接管了,返回 application/json。

解决:SSE 方法内部 try-catch,用 emitter.completeWithError(e) 或推一个 event: error 的 SSE 事件,别让全局处理器碰它。

10.5 上下文 token 超限 ​

现象:聊到后面报 400(context length exceeded)。

解决:

  • 只取最近 10 轮;
  • 维护 token 计数(message.token_count),超预算先截断最早的;
  • 更早的对话压成 conversation.summary;
  • 输入侧限制单条消息长度(比如 2000 字)。

10.6 线程池被打满 ​

现象:接口整体变慢,jstack 里大量线程卡在模型调用。

原因:每个 SSE 连接占一个 Tomcat 线程等模型返回。

解决:请求线程立刻返回 SseEmitter,业务丢到独立线程池;线程池用有界队列 + CallerRuns,并在队列水位 > 70% 时告警。

10.7 消息顺序错乱 ​

现象:用户连发两条,回复顺序反了。

原因:两条请求并发,谁先返回谁先落库。

解决:同一会话加锁(Redisson,key = conv:{id}),或前端串行化发送。


十一、复盘:做得好的 / 可以更好的 ​

做得好的

  1. 把 AI 的不确定性收敛掉了:风险识别走固定话术、测评走规则引擎,AI 只做「表达层」。
  2. 成本可算:ai_usage 表让每一分钱都能归因到用户和模型。
  3. 降级完整:模型超时 → 固定话术;RAG 空召回 → 不硬答,直接说「资料里没有」。
  4. 踩过的坑都沉淀成了笔记(就是这一篇)。

可以更好的

  1. 缺少评估体系:RAG 的评估集是后期补的,早期优化全靠感觉。
  2. 没有灰度:Prompt 改动直接全量,出问题只能回滚代码。
  3. 监控偏后:先上线再补的 token 用量和降级率监控,中间有几天是瞎的。
  4. ai_usage 写入是同步的:高并发下它会成为新的瓶颈,应该改成异步 + 批量。

十二、面试怎么讲(STAR) ​

12.1 一分钟版 ​

我独立做了一个心理健康 AI 助手(Spring Boot 3 + Spring AI + MySQL + Redis,前端微信小程序)。核心是把大模型做成可控的产品能力:流式对话用 SSE + Spring AI,首字延迟压到 1 秒内;风险话术和量表计分不交给模型,保证合规和可复现;上下文做了「滑动窗口 + 摘要 + 用户画像」三级记忆,解决了长对话跑偏;成本上建了用量表,能按用户和模型归因。踩过 Nginx 缓冲、EventSource 带不了 header、全局异常吞流这几个坑,都有排查过程和记录。

12.2 三分钟版 ​

在 1 分钟版基础上,按「问题 → 方案 → 取舍 → 数据」讲其中一条链路,比如上下文管理:

一开始只做滑动窗口取最近 10 轮,结果用户聊到 30 轮后,前面说的关键信息(在准备考研)丢了,回答开始跑偏。我的方案是加一层异步摘要:超过 10 轮就把更早的对话压缩进 conversation.summary,并在每次请求前做 token 预算,超了先砍历史、再砍 RAG 条数。取舍是摘要要多一次模型调用、有信息损耗,所以我加了 summary_version,Prompt 改版后能自动重建。上线后长会话的「答非所问」反馈基本消失。

12.3 追问准备(10 个高频) ​

  1. 为什么用 SSE 不用 WebSocket?→ 单向推送够用、基于 HTTP、自带重连。
  2. EventSource 怎么带 token?→ 一次性 ticket,不把 JWT 放 URL。
  3. Nginx 为什么要关缓冲?→ 否则攒批发送,打字机效果失效。
  4. 全局异常为什么会破坏流?→ 返回 JSON 而非 text/event-stream。
  5. 上下文超限怎么办?→ 预算 + 三级记忆 + 截断顺序。
  6. RAG 效果怎么评估?→ 人工标注集 + Recall@K + 忠实度。
  7. 模型挂了怎么办?→ 超时 → 重试 → 备用模型 → 固定话术四层降级。
  8. 成本怎么控?→ max_tokens + 模型路由 + 缓存 + 用户配额,ai_usage 归因。
  9. 怎么防 Prompt 注入?→ 权限不交给模型、工具内部二次鉴权、输入输出过滤。
  10. 你最大的技术挑战是什么?→ 挑一个真实的、有排查过程的(推荐 10.6 线程池打满,或 10.1 断流)。