我用 Vue3 + LangGraph 做了个心理咨询 AI,踩了 9 个坑后总结出来的实战经验

TL;DR:这是一个从 0 到 1 的心理咨询师智能体实战项目,技术栈是 Vue3 + NestJS + LangGraph 1.x + PostgreSQL。核心思路是用状态图把"危机安全筛查"焊死在对话流程里,配合 SSE 流式输出和双套持久化,做到无 Key 无数据库也能完整跑通。

实现效果如下:

写在前面

大家好,这是我最近折腾的一个小项目------一个心理咨询陪伴类的 AI 助手。

之所以想做这个,是因为我发现心理咨询场景和普通聊天机器人有个本质区别:回复不好只是体验问题,漏掉一次高危信号就是安全问题。

所以从一开始我就定了条死规矩:安全这件事,不能全交给大模型,代码必须兜底。

这篇文章是我边踩坑边迭代出来的记录,肯定有很多不足,写出来主要是想跟大家交流学习,欢迎大佬们指点。

一、为什么要做"智能体",而不是简单调个 API?

最开始我也想偷懒------不就是调一下大模型接口嘛,传历史消息,拿回复返回去,完事。

但真动手写了个 demo 之后,发现根本不是那么回事:

  1. 对话状态:用户是刚进来随便聊聊,还是已经情绪崩溃了?不同阶段回复策略完全不同。

  2. 流程分支:同一句"我好累",可能只是加班吐槽,也可能是自杀前兆,要走完全不同的处理链路。

  3. 长记忆:多轮对话得记住前面聊过什么,不能用户上一句说"我三个月前失业了",下一句就忘了。

  4. 安全闸门:风险等级这种判断,必须由确定性代码执行,大模型说了不算。

这四个需求一摆出来,我就明白了:这不是一个"调 API"的活,这是一个智能体的活------得给它画一张流程图,规定好第一步干嘛、第二步干嘛、什么情况下走哪条路。

LangGraph 的状态图刚好能干这个。

二、技术选型与整体架构

技术栈一览

层级 技术选型 版本 选择理由
前端框架 Vue3 + TypeScript + Vite 3.5 / Vite 8 组合式 API 写流式状态很顺手
对话 UI MateChat + DevUI 1.14 / 1.6 自带聊天气泡、输入框,不用重复造轮子
服务端 NestJS 11.2 模块化 + 依赖注入,存储和模型都可替换
智能体编排 LangGraph.js 1.4 状态图 + 条件分支 + 检查点,天生适合多轮对话
模型接入 LangChain.js 1.2 / 1.6 ChatOpenAI 同时覆盖 OpenAI 和阿里云百炼
数据库 PostgreSQL + pg 16 / 8.23 业务数据和智能体检查点共用一套库
参数校验 zod 4.6 请求校验 + 前后端共享类型

整体架构图

flowchart TB subgraph Browser[&#34;🖥️ 浏览器 (Vue 3 前端)&#34;] direction TB CC[&#34;**CounselorChat.vue**<br/>会话主界面 / 编排 SSE 流&#34;] subgraph UI[&#34;UI 子组件&#34;] direction LR SS[&#34;**SessionSidebar.vue**<br/>会话列表 / 运行时状态 / 风险标记&#34;] CB[&#34;**CrisisBanner.vue**<br/>命中高风险展示求助资源&#34;] MB[&#34;**McBubble + McMarkdownCard**<br/>SSE 流式增量渲染&#34;] end SVC[&#34;**services/counselor.ts**<br/>fetch + ReadableStream 手动解析 SSE&#34;] CC --> SS CC --> CB CC --> MB CC --> SVC end SVC -- &#34;SSE 事件流<br/>meta → token 分片 → done&#34; --> SVC Browser ==>|&#34;POST /api/counselor/chat<br/>(Vite 代理 /api → :3000)&#34;| CTRL subgraph Nest[&#34;⚙️ NestJS 后端&#34;] direction TB CTRL[&#34;**CounselorController**<br/>SSE 响应头 + AbortController 中断控制&#34;] SERVICE[&#34;**CounselorService**<br/>组装事件流,写入业务消息库&#34;] CTRL --> SERVICE subgraph Graph[&#34;LangGraph 智能体 (buildCounselorGraph)&#34;] direction TB START((&#34;START&#34;)) --> SAFE[&#34;**safetyScreen**<br/>安全筛查&#34;] SAFE -- &#34;high 高风险&#34; --> CRISIS[&#34;**crisisResponse**<br/>危机干预响应&#34;] SAFE -- &#34;普通风险&#34; --> MODEL[&#34;**counselor**<br/>模型生成回复&#34;] end SERVICE --> Graph subgraph Support[&#34;支撑组件&#34;] direction LR RISK[&#34;**assessRisk()**<br/>纯函数危机筛查&#34;] LLM[&#34;**createCounselorModel()**<br/>OpenAI / DashScope / 本地演示模型&#34;] STORE[&#34;**SessionStore**<br/>Postgres 存储 / 内存存储&#34;] end RISK -.-> SAFE LLM -.-> MODEL STORE -.-> SERVICE end subgraph PG[&#34;🗄️ PostgreSQL&#34;] direction LR BIZ[&#34;**业务读模型**<br/>counselor_session<br/>counselor_message&#34;] CKPT[&#34;**LangGraph 检查点**<br/>checkpoints<br/>checkpoint_writes / ...&#34;] end STORE ==> BIZ Graph ==> CKPT CTRL -. &#34;SSE 回传&#34; .-> SVC classDef browser fill:#e3f2fd,stroke:#1976d2,stroke-width:2px,color:#0d47a1 classDef nestjs fill:#fce4ec,stroke:#c2185b,stroke-width:2px,color:#880e4f classDef graphNode fill:#fff3e0,stroke:#ef6c00,stroke-width:2px,color:#e65100 classDef support fill:#f3e5f5,stroke:#7b1fa2,stroke-width:1px,color:#4a148c classDef db fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#1b5e20 class CC,SS,CB,MB,SVC browser class CTRL,SERVICE nestjs class START,SAFE,CRISIS,MODEL graphNode class RISK,LLM,STORE support class BIZ,CKPT db

一次对话的完整流程

  1. 用户输入消息,前端先显示用户气泡,再放一个空的助手气泡在那 loading

  2. POST 请求发到后端,后端立刻返回 SSE 流

  3. 后端先把用户说的话存下来------就算后面模型调用失败,用户的话也不会丢

  4. 进入 LangGraph 状态图,第一步永远是安全筛查

  5. 如果是高危,直接返回预设的危机话术,根本不调大模型;否则交给大模型生成回复

  6. 后端一边生成一边往前端推:先推风险等级和咨询阶段(meta 事件),再逐字推 token,最后推 done

  7. 前端收到 token 就往气泡里拼,done 了就关掉 loading,刷新侧边栏

三、核心实现:我是怎么把"安全"焊死在流程里的

3.1 状态图:安全筛查是绕不过去的第一步

LangGraph 1.x 的写法跟老版本差别挺大的(这个后面踩坑部分会说)。我用 Annotation.Root 定义了整个对话的状态,里面有几个关键字段:

  • riskLevel:风险等级------这个字段只能由代码写入,大模型改不了

  • riskEvidence:命中的原文片段,方便排查误判

  • phase:咨询阶段(刚进来 / 深入探索 / 危机干预等)

typescript 复制代码
// src/counselor/agent/state.ts
import { Annotation, MessagesAnnotation } from "@langchain/langgraph";

const replace = <T>(_prev: T, next: T): T => next;

export const CounselorState = Annotation.Root({
  ...MessagesAnnotation.spec,  // 复用官方的消息管理
  sessionId: Annotation<string>({ reducer: replace, default: () => "" }),
  riskLevel: Annotation<RiskLevel>({ reducer: replace, default: () => "none" }),
  riskEvidence: Annotation<string[]>({ reducer: replace, default: () => [] }),
  phase: Annotation<CounselingPhase>({ reducer: replace, default: () => "intake" }),
});

然后画图的时候,安全筛查节点是第一个节点,没有任何捷径可以绕过去:

typescript 复制代码
// src/counselor/agent/graph.ts
const workflow = new StateGraph(CounselorState)
  .addNode("safetyScreen", safetyScreenNode)      // 第一步:确定性筛查
  .addNode("crisisResponse", crisisResponseNode) // 高危路径:固定脚本
  .addNode("counselor", createCounselorNode(model)) // 普通路径:调模型
  .addEdge(START, "safetyScreen")  // 从 START 进来,必须先过筛查
  .addConditionalEdges("safetyScreen", routeAfterScreening, {
    crisis: "crisisResponse",
    counselor: "counselor",
  })
  .addEdge("crisisResponse", END)
  .addEdge("counselor", END);

这里我觉得最大的好处是:"高危时必须给求助热线"这件事,可以直接写单元测试断言,不用靠大模型评测。 因为危机响应是个纯函数节点,输入什么、输出什么,完全确定。

3.2 风险筛查:正则规则是底线,还要处理否定句

我现在的实现是用正则规则做确定性筛查,覆盖自杀意念、准备行为、自伤、伤人、无望感这些场景。

这里踩过一个特别坑的问题:否定句误判。

比如用户说"我不会自杀的,我只是最近很累"------如果直接正则匹配"自杀"两个字,系统就会弹出危机热线,这反而会让用户觉得"你根本没听懂我在说什么"。

所以我加了否定句处理逻辑:命中关键词之前,如果有"不会""没有""从没""不再"这些否定词,就跳过这条规则。

typescript 复制代码
const NEGATION_BEFORE = /(不会|没有|从没|不再|不想再|从未|没想过)s{0,4}(去)?s{0,4}$/;

function findEvidence(text: string, pattern: RegExp): string | undefined {
  // 先分句,再看命中位置前面有没有否定词
  for (const sentence of splitSentences(text)) {
    if (!pattern.test(sentence)) continue;
    const index = pattern.exec(sentence)?.index ?? 0;
    if (NEGATION_BEFORE.test(sentence.slice(0, index))) continue;
    return sentence;
  }
  return undefined;
}

我还写了一组单元测试,覆盖了各种情况:正常聊天、轻度情绪、有自杀想法、有准备行为、否定句......相当于把规则的预期行为"锁死"了。

3.3 SSE 流式:既要逐字出,又要知道状态

这个需求听起来简单,但实际写的时候挺头疼的:

  • 前端需要逐字收到 token,才能做出那种"打字机"效果

  • 同时前端还需要知道这一轮的风险等级和咨询阶段,好显示对应的标记

LangGraph 1.x 支持同时订阅两种模式:streamMode: ['messages', 'updates'],一次订阅两边都拿到。

typescript 复制代码
const config = {
  configurable: { thread_id: input.sessionId },
  streamMode: ['messages', 'updates'],
  signal,  // 前端断开时能中断
};

const stream = await this.graph.stream(input, config);
for await (const chunk of stream) {
  const [mode, payload] = chunk;
  if (mode === 'updates') {
    // 拿到节点更新的状态(风险等级、阶段)
  }
  if (mode === 'messages') {
    // 拿到模型逐片产出的 token
  }
}

这里有个非常关键的坑:不是所有节点都会产生流式分片。

crisisResponse 节点直接返回一整段固定话术,它不经过大模型,所以不会逐字吐 token。如果不做处理,前端就会看到一个空空的助手气泡------用户点了发送,然后什么都没发生。

所以我加了兜底逻辑:如果一轮下来一个 token 都没收到,就从最终状态里把完整回复读出来,整段作为一个 token 事件推给前端。

typescript 复制代码
// 兜底:没有流式分片时,直接读完整回复
if (!streamed) {
  const answer = await this.readLastReply(input.sessionId);
  if (answer) {
    yield { type: 'token', content: answer };
  }
}

3.4 两套存储:各干各的活,别互相将就

这是整个项目里我觉得收获最大的一个设计决策。

LangGraph 自带的 PostgresCheckpointer,会把对话状态序列化后存在二进制 blob 字段里。它特别适合做智能体的记忆恢复、断点续跑。但如果你想用它来做"会话列表分页""历史消息查询""风险统计",那会非常痛苦------blob 字段你怎么排序?怎么做检索?

所以我搞了两套存储,各司其职:

存储 干什么用 表
checkpointer 智能体运行状态、多轮记忆、断点续跑 checkpoints 系列表
业务读模型 会话列表、历史消息、风险统计、审计 counselor_session / counselor_message

写的时候两边都写一份,读的时候各取所需:页面展示走读模型,智能体恢复记忆走 checkpointer。

3.5 没有 API Key 也能跑:三级降级策略

这个项目我做了一个特别开心的设计------三级降级:

  1. 有 PostgreSQL 就连 PostgreSQL,没有就用内存存储

  2. 有模型 API Key 就用真模型,没有就自动降级到离线演示模型

  3. 离线演示模型是我自己继承 BaseChatModel 写的,模拟逐字流式输出

这样带来的好处是:就算你在一台没有网络、没有数据库的电脑上,这套系统也能完整跑通。 本地调试方便,CI 跑测试也方便------不用依赖外部环境。

typescript 复制代码
export class DemoCounselorModel extends BaseChatModel {
  // 非流式调用走这里
  async _generate(messages) { ... }
  // 流式调用走这里:按 4 个字符一片吐出,模拟逐字效果
  async *_streamResponseChunks(messages) {
    for (const token of text.match(/[sS]{1,4}/g)) {
      await delay(8);
      yield new ChatGenerationChunk({ text: token });
    }
  }
}

3.6 前端:手动解析 SSE 流

浏览器原生的 EventSource 只支持 GET 请求,但我们对话需要 POST 带 JSON 体,所以只能用 fetch + ReadableStream 手动解析。

要点是:SSE 分片可能被 TCP 截断,不能每次收到数据就直接解析。得先放缓冲区里,按 nn 分割事件块,没拼完的留到下一轮。

Vue 这边还踩了一个响应式的坑:你 push 进数组的裸对象不是响应式代理,直接改它的属性不会触发视图更新。

typescript 复制代码
messages.value.push({ content: '', streaming: true });
// 重点:从数组里重新取出来,拿到的才是响应式代理
const reply = messages.value[messages.value.length - 1];
// 之后改 reply.content,UI 才会正常更新

四、踩坑记录:这些都是我真金白银换回来的经验

坑 1:网上教程全是老版本 API,照抄就报错

LangChain / LangGraph 1.x 跟 0.x 的 API 变化非常大。modelName 改成了 model,MessageGraph 整个废弃了,streamEvents 也换成了 streamMode......

经验:动手前先去 node_modules 里翻 .d.ts 文件,别相信网上的博客。

0.x 写法(已废弃) 1.x 正确写法
new ChatOpenAI({ modelName }) new ChatOpenAI({ model })
MessageGraph StateGraph + MessagesAnnotation
graph.streamEvents(...) graph.stream(input, { streamMode: [...] })

坑 2:@nestjs/config 升级成纯 ESM 了,跟 CJS 构建冲突

不想为了读个 .env 引入一个 ESM-only 依赖,我干脆手写了一个简单的 .env 解析逻辑,用 Node 内置能力搞定。反而少了一个依赖,而且系统环境变量优先级高于 .env 文件,语义更正确。

坑 3:DevUI 的老样式把 LightningCSS 搞崩了

DevUI 里保留了一些 IE 时代的 CSS hack 语法,LightningCSS 严格模式直接构建失败。解决办法是开 errorRecovery: true,让它跳过异常规则。

坑 4:按需引入组件,但图标没注册

我为了压包体积,没有全量引入 DevUI,只注册了用到的组件。结果控制台一直报 d-icon 找不到------因为 d-button 内部是用 resolveComponent('d-icon') 字符串解析的,按需引入 button 不会自动带上 icon 模块。

手动注册 icon 模块之后,首屏主包从 4.57 MB 降到了 1.53 MB。

坑 5:流式的"最后一公里"------不是所有节点都会分片

这个前面说过了:危机路径是整段返回的,没有逐字分片,必须做兜底,否则前端空气泡。

坑 6:Vue 响应式------push 进去的裸对象不是代理

这个也前面说过了,push 完要从数组里取回来才是响应式的。

坑 7:同一接口两套实现,行为悄悄跑偏

Postgres 存储和内存存储实现了同一个接口,但我写着写着,两边的逻辑就不一样了------Postgres 版在 SQL 里加了"只有默认标题时才用首句命名"的判断,内存版却漏了。

经验:同一接口的多实现是"行为漂移"的高发区,一定要写契约测试锁住行为。

坑 8:配置静默失败,运行时才报错

.env 里明明写了 DashScope 的 Key,但 LLM_PROVIDER 还是模板里默认的 openai,程序静默不报错,直到第一次请求才抛出 401。

所以我现在的做法是:启动时就把最终生效的配置打印出来,而且如果指定的供应商没 Key,自动回退到另一个有 Key 的供应商并打警告日志。

坑 9:pnpm 每次执行脚本都想重装依赖

项目里的 node_modules 与当前解析出的 pnpm store 位置不一致,在 pnpm-workspace.yaml 里显式声明 storeDir 就解决了。

五、我觉得这个项目还不错的几个设计点

  1. 安全不可绕过:风险筛查是状态图第一个必跑节点,高风险直接固定脚本接管,模型插不上手

  2. 双层持久化:智能体记忆和业务查询各用各的存储,互不将就

  3. 三级降级:无库无 Key 也能跑通全链路,本地调试和 CI 测试都方便

  4. 咨询阶段可推导:根据对话轮次推导阶段,不额外消耗模型调用

  5. 可观测:启动打印配置,SSE 带元信息,运行时状态接口暴露

  6. 前端体验完整:流式打字、风险标记、危机横幅、移动端适配

六、怎么验证它真的能用?

bash 复制代码
# 后端:lint + 类型检查 + 单元测试 + e2e + 编译
cd psychologist-agent-api
pnpm lint && pnpm exec tsc -p tsconfig.json --noEmit
pnpm test          # 风险规则、会话存储、graph 逻辑
pnpm run test:e2e  # e2e 用演示模型+内存存储,不需要数据库和网络
pnpm run build

# 前端
cd ../psychologist-agent-web
pnpm build

e2e 测试会离线验证几个关键行为:普通对话能流式输出、高危输入强制返回危机脚本不走模型、消息按顺序落库。

七、后续还想继续折腾的方向

这个项目更多是个技术原型,离生产级产品还差得远。我后面想继续完善的方向:

  1. 风险检测升级:规则兜底 + 模型辅助,但模型只能提高风险等级,不能降低

  2. 规则表外置:把危机词表从代码里挪到配置或数据库,让心理专业人员也能维护

  3. 黄金测试对话集:收集典型咨询场景,自动化断言"有没有共情""有没有给热线",防止改 prompt 后行为退化

  4. 前端包体积优化:MateChat 自带的 mermaid、katex 做动态 import 懒加载

  5. 生产化补齐:鉴权多租户、数据脱敏、审计日志、接 LangSmith 监控

写在最后

做完这个项目最大的感受是:做 AI 应用,调用大模型其实只占很小一部分工作量。

更多时间是花在画清楚边界上------哪些判断必须由确定性代码做、哪些状态存在哪里、失败了怎么优雅降级。

把这些边界想清楚,再配上充分的测试,整个系统才会比较稳。

以上都是我个人边踩坑边摸索出来的,肯定有很多考虑不周的地方,欢迎大家在评论区一起交流指正 👇

相关推荐
数据掘金1 小时前
A/B 实验分组参数回滚后还在生效?一次参数校验体系的重构复盘
前端
bmxy小明同学2 小时前
2026-10-09-token与成本
java·前端·数据库
空堂与归2 小时前
REA登顶GitHub:AI如何逆向一切软件
ai·大模型·agent
wewowilling2 小时前
RAG知识库--coze
大模型·agent·知识库·rag·coze
neoooo2 小时前
🚀Claude Code 小技巧(二):找回会话,撤销修改
前端·人工智能·后端
DigitalOcean2 小时前
DigitalOcean 无服务器推理实测:六款模型的首 Token 延迟表现
llm·agent
今年下半年2 小时前
【文件上传】记录一次大附件上传的处理方案
前端·文件上传·内存溢出·分段上传
溪语流沙2 小时前
【每天一个CSS | Day21】落地页Hero动画的分镜表
前端·css
效率工作实验室2 小时前
2026年企业级 AI 智能体平台选型指南:关键评估维度
ai·agent·企业级·智能体平台·智能体平台选型