ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Next.js + LangGraph.js 实战:构建多步骤有状态简历优化 AI Agent

Next.js + LangGraph.js 实战:构建多步骤有状态简历优化 AI Agent 简历工具这个赛道看起来简单实际上坑特别多。我前后做过三版简历相关的 AI 应用第一版用纯 Prompt 调大模型 API第二版上了 RAG 做岗位匹配到第三版才真正把 Next.js LangGraph.js 这套组合跑通。前两版的问题很典型单轮对话撑不起改简历这种多步骤任务用户说一句帮我优化一下模型要么瞎改一通要么改完就忘了前面聊过什么。LangGraph.js 解决的就是这个多步骤、有状态、可回退的问题而 Next.js 负责把整个交互体验和流式输出做顺。这篇内容我打算把整个落地过程拆开讲包括为什么选 LangGraph.js 而不是直接写个 while 循环、简历解析和岗位匹配这两个核心节点怎么设计、状态图怎么画、流式输出怎么接、部署时踩了哪些坑。适合已经会 Next.js、想上手 AI Agent 但不知道从哪下手的开发者也适合做过单轮 Prompt 应用、想升级到多步骤 Agent 的同学。全文基于我实际跑通的版本代码和配置都能直接抄。1. 为什么简历工具非得上 Agent 架构1.1 单轮 Prompt 在简历场景的三个死穴先说清楚问题不然容易为了用框架而用框架。简历工具的核心任务不是生成一段文字而是理解一份简历 理解一个岗位 做出一系列修改决策 保持前后一致。这三件事叠在一起单轮 Prompt 直接崩。第一个死穴是多步骤依赖。用户上传简历后典型流程是解析简历结构 → 提取关键信息 → 分析岗位 JD → 找出匹配缺口 → 生成修改建议 → 逐条应用修改 → 校验修改后是否还通顺。这里面每一步都依赖上一步的输出而且中间可能需要用户确认。你用一次 API 调用把这些全塞进去模型会偷懒经常跳过分析直接给建议建议还很泛。第二个死穴是状态保持。用户改到一半说刚才那个项目经历再突出一下数据单轮调用根本不知道刚才那个指的是哪一段。你得把整个对话历史 简历当前状态 已应用的修改全部塞进 contexttoken 消耗爆炸不说模型还容易抓错重点。第三个死穴是可回退和可干预。简历修改是个主观性很强的活用户经常说这条建议我不采纳换一个方向。单轮模式下你只能重新生成之前的工作全丢。Agent 架构下每个节点是一个独立步骤可以单独重跑某个节点前面的结果保留。我实测过一个对比同一份简历 同一个岗位 JD单轮 Prompt 方案平均要 4-5 次重试才能得到用户满意的结果Agent 方案基本 1-2 次就能收敛。差距就在分步骤 有状态上。1.2 LangGraph.js 相比手写状态机的实际优势有人会问我自己写个状态机 一堆 if-else 不就行了为什么要引入 LangGraph.js我一开始也是这么想的第二版就是手写的写到后面发现几个问题。手写状态机最麻烦的是条件分支和循环。简历修改流程里有个典型循环生成建议 → 用户反馈 → 如果用户不满意就重新生成 → 满意就应用。这个循环用 if-else 写出来是一坨嵌套而且状态传递全靠手动管理加一个新节点就要改一堆地方。LangGraph.js 的核心价值在于它把节点 边 状态这三件事抽象出来了。你定义好每个节点做什么、节点之间怎么跳转、共享状态长什么样剩下的调度、循环、条件分支它帮你管。更关键的是它原生支持流式输出和中断恢复这两个在简历工具里都是刚需。还有一个实际好处是可视化调试。LangGraph 的状态图可以直接导出成图你能一眼看到流程哪里绕了、哪里断了。手写状态机出 bug 的时候你只能靠打日志一点点追。不过要说清楚LangGraph.js 不是银弹。如果你的任务就是单轮问答别用它纯属增加复杂度。它适合的是多步骤 有状态 需要人工干预的场景简历工具刚好全中。1.3 技术选型的边界什么规模的项目适合这套组合不是所有简历工具都要上这套。我给个判断标准项目类型推荐方案理由纯简历模板填充前端模板 表单不需要 AI单次简历润色单轮 Prompt API一次调用能搞定简历 岗位匹配 多轮修改Next.js LangGraph.js多步骤有状态企业级批量简历筛选后端服务 队列 Agent需要并发和持久化我做的这个工具属于第三类核心场景是用户上传简历 粘贴岗位 JD 多轮对话式修改。如果你只是做个一键美化简历的小工具真没必要上 LangGraph杀鸡用牛刀。另外提醒一点LangGraph.js 目前生态还在快速迭代API 偶尔会有 breaking change。我建议锁定版本号别用 latest不然某天部署上去发现跑不起来就很尴尬。我锁的是 0.2.x 的一个稳定版本具体版本号在 package.json 里写死。2. 项目骨架搭建与依赖版本锁定2.1 Next.js App Router 的目录结构设计我用的是 Next.js 14 的 App Router。目录结构这块我踩过坑第一版把所有逻辑塞在app/api里后来发现 Agent 的状态管理代码和路由代码混在一起改起来很痛苦。第三版重新组织了一下resume-agent/ ├── app/ │ ├── api/ │ │ └── agent/ │ │ └── route.ts # 流式接口入口 │ ├── chat/ │ │ └── page.tsx # 对话主界面 │ └── layout.tsx ├── lib/ │ ├── agent/ │ │ ├── graph.ts # LangGraph 状态图定义 │ │ ├── state.ts # 状态类型定义 │ │ ├── nodes/ # 各个节点 │ │ │ ├── parseResume.ts │ │ │ ├── analyzeJD.ts │ │ │ ├── matchGap.ts │ │ │ ├── generateSuggestion.ts │ │ │ └── applyEdit.ts │ │ └── tools/ # 工具函数 │ ├── llm/ │ │ └── client.ts # 模型客户端封装 │ └── types/ │ └── resume.ts # 简历数据结构 ├── components/ │ ├── ResumeUploader.tsx │ ├── ChatPanel.tsx │ └── DiffViewer.tsx # 修改对比展示 └── package.json关键点是把 Agent 逻辑和 Next.js 路由解耦。lib/agent里全是纯逻辑不依赖 Next.js 的任何东西这样单元测试好写将来想换框架也不用重写。app/api/agent/route.ts只负责接收请求、调用 graph、把流式结果吐回去。这个结构还有个好处是节点可以单独测试。我写了个脚本直接调parseResume节点喂一份简历进去看输出不用起整个 Next.js 服务调试效率高很多。2.2 依赖清单与版本踩坑记录依赖这块我列个实际用的清单版本号是我验证过能跑通的组合{ dependencies: { next: 14.2.3, react: 18.3.1, langchain/langgraph: 0.2.5, langchain/core: 0.3.15, langchain/openai: 0.3.11, zod: 3.23.8, ai: 3.4.7 } }踩过的坑说几个。第一个是langchain/langgraph和langchain/core的版本必须匹配我一开始 core 装了个旧版本graph 跑起来报Cannot read property Channel of undefined查了半天是版本不兼容。第二个是ai这个包Vercel 的 AI SDK它和 LangGraph 的流式输出格式不一样需要做一层转换后面流式那节细讲。第三个坑是Node 版本。LangGraph.js 有些特性依赖 Node 18 的AsyncLocalStorage我用 Node 16 跑的时候流式输出会丢状态。建议直接上 Node 20 LTS省心。提示装依赖的时候别用--force或--legacy-peer-deps硬装版本冲突就老老实实调版本号硬装出来的依赖树后面会以各种诡异的方式报错。2.3 环境变量与模型接入的配置细节环境变量我用了这几个# .env.local OPENAI_API_KEYsk-xxx OPENAI_BASE_URLhttps://api.openai.com/v1 MODEL_NAMEgpt-4o-mini这里有个经验简历工具不需要用最贵的模型。我一开始用 gpt-4o效果是好但成本扛不住用户改一次简历要跑七八个节点每个节点都调一次模型。后来换成 gpt-4o-mini配合好的 Prompt 和结构化输出效果差距不大成本降了十几倍。模型客户端我封装了一层主要是为了统一处理重试和超时// lib/llm/client.ts import { ChatOpenAI } from langchain/openai; export const llm new ChatOpenAI({ modelName: process.env.MODEL_NAME || gpt-4o-mini, temperature: 0.3, maxRetries: 2, timeout: 30000, configuration: { baseURL: process.env.OPENAI_BASE_URL, }, });temperature设 0.3 是有讲究的。简历修改需要一定的创造性比如换个说法但又不能太飘比如编造经历。0.3 是我试了 0、0.3、0.7 之后选的0 太死板0.7 会瞎编0.3 刚好。maxRetries: 2也是踩坑加的。模型 API 偶尔会抽风返回 429 或 500不加重试的话用户那边直接看到报错体验很差。重试两次基本能覆盖大部分偶发问题。3. LangGraph 状态图的核心节点设计3.1 状态结构定义简历 Agent 的共享内存长什么样LangGraph 的核心是状态State。所有节点读写同一个状态对象节点之间通过状态传递数据。简历 Agent 的状态我定义成这样// lib/agent/state.ts import { Annotation } from langchain/langgraph; export const ResumeState Annotation.Root({ // 原始输入 rawResume: Annotationstring, jobDescription: Annotationstring, // 解析后的结构化数据 parsedResume: AnnotationResumeData | null, parsedJD: AnnotationJDData | null, // 分析结果 gaps: AnnotationGap[], suggestions: AnnotationSuggestion[], // 对话相关 messages: AnnotationBaseMessage[]({ reducer: (prev, next) prev.concat(next), default: () [], }), // 当前阶段 stage: Annotationstring, // 用户反馈 userFeedback: Annotationstring | null, });这里有几个设计决策值得说。messages用了reducer意思是每次节点返回新消息时是追加而不是覆盖。这是 LangGraph 里处理对话历史的标准做法不写 reducer 的话每次都会被覆盖掉。parsedResume和parsedJD用null作为初始值是为了区分还没解析和解析了但是空的。这个区分在条件路由里很有用后面讲路由的时候会用到。stage字段是我自己加的用来标记当前流程走到哪一步。LangGraph 本身有节点名但节点名是给调度用的stage是给业务逻辑和前端展示用的。比如前端要根据stage决定显示正在解析简历还是正在生成建议。3.2 简历解析节点从非结构化文本到结构化数据简历解析是整个流程的第一步也是最容易出问题的一步。用户上传的简历格式千奇百怪PDF、Word、纯文本都有而且排版五花八门。我的做法是先用工具把文件转成纯文本再让模型做结构化提取。// lib/agent/nodes/parseResume.ts import { z } from zod; import { llm } from /lib/llm/client; const ResumeSchema z.object({ basicInfo: z.object({ name: z.string(), email: z.string().optional(), phone: z.string().optional(), }), education: z.array(z.object({ school: z.string(), major: z.string(), degree: z.string(), period: z.string(), })), experience: z.array(z.object({ company: z.string(), role: z.string(), period: z.string(), highlights: z.array(z.string()), })), skills: z.array(z.string()), }); export async function parseResume(state: typeof ResumeState.State) { const structured llm.withStructuredOutput(ResumeSchema); const result await structured.invoke([ { role: system, content: 你是一个简历解析专家。从下面的简历文本中提取结构化信息。 要求 1. 不要编造任何信息原文没有的字段留空 2. highlights 提取工作经历中的具体成果保留数字 3. skills 去重并归类, }, { role: user, content: state.rawResume }, ]); return { parsedResume: result, stage: parsed, }; }这里用了withStructuredOutput Zod schema这是 LangChain 里做结构化输出的标准姿势。好处是模型返回的 JSON 会被自动校验不符合 schema 会重试。我试过不用 schema 直接让模型返回 JSON十次里有两次格式是错的加了 schema 之后基本没出过错。Prompt 里那句不要编造任何信息是必须的。我测试的时候发现如果简历里没写邮箱模型会贴心地编一个exampleemail.com出来。这种编造在简历场景是致命的用户拿去投递就露馅了。还有一个细节是highlights的提取。我要求保留数字因为简历里最有价值的就是量化成果。模型有时候会把提升了 30% 效率简化成提升了效率这就把关键信息丢了。明确要求保留数字之后提取质量明显提升。3.3 岗位匹配节点把 JD 拆成可对比的维度岗位匹配是简历工具的核心价值点。用户想知道的是我的简历和这个岗位差在哪而不是这个岗位要求什么。所以这个节点的任务是把 JD 拆成可对比的维度然后和简历逐项对比。// lib/agent/nodes/matchGap.ts const GapSchema z.object({ gaps: z.array(z.object({ dimension: z.string(), // 维度技能/经验/学历等 requirement: z.string(), // 岗位要求 current: z.string(), // 简历现状 severity: z.enum([high, medium, low]), suggestion: z.string(), // 改进方向 })), }); export async function matchGap(state: typeof ResumeState.State) { const structured llm.withStructuredOutput(GapSchema); const result await structured.invoke([ { role: system, content: 对比简历和岗位要求找出差距。 severity 判断标准 - high: 岗位硬性要求简历完全没有 - medium: 岗位要求简历有但不突出 - low: 加分项简历可以补充, }, { role: user, content: 简历${JSON.stringify(state.parsedResume)} 岗位${JSON.stringify(state.parsedJD)}, }, ]); return { gaps: result.gaps, stage: matched }; }severity这个字段是我加了之后觉得最值的设计。一开始没有分级所有差距平铺给用户用户看完一脸懵不知道先改哪个。加了 severity 之后前端可以按严重程度排序用户一眼看到哦这个岗位要求 Kubernetes我简历里完全没提这是 high。Prompt 里对 severity 的判断标准写得很具体这是关键。如果你只写判断严重程度模型会按自己的理解来同一个差距这次判 high 下次判 medium。给了明确标准之后一致性好了很多。3.4 建议生成与修改应用节点的拆分逻辑建议生成和修改应用我拆成了两个节点这是有意的。一开始我合成一个节点让模型直接输出修改后的简历结果发现两个问题一是用户看不到改了什么二是用户想只采纳部分建议时没法操作。拆开之后generateSuggestion节点只负责生成建议列表每条建议包含原文和建议改成。applyEdit节点负责把用户选中的建议应用到简历上。// lib/agent/nodes/generateSuggestion.ts const SuggestionSchema z.object({ suggestions: z.array(z.object({ id: z.string(), target: z.string(), // 针对简历的哪个部分 original: z.string(), // 原文 revised: z.string(), // 建议改成 reason: z.string(), // 修改理由 })), });original和revised这两个字段是 DiffViewer 组件的基础。前端拿到这两个字段就能渲染出删除线 高亮的对比效果用户一眼看到改了什么。这个体验比直接给一份新简历好太多用户有掌控感。reason字段也不能省。用户不是无脑接受建议的你得告诉他为什么这么改。比如把负责项目管理改成主导 3 人团队完成 XX 项目提前 2 周交付因为原表述太笼统缺乏量化成果。有了理由用户才会信任这个工具。4. 状态图的边与条件路由设计4.1 主流程的线性边与入口点设置节点定义好了接下来是把它们连起来。LangGraph 里用addEdge连线性流程用addConditionalEdges连条件分支。// lib/agent/graph.ts import { StateGraph, START, END } from langchain/langgraph; import { ResumeState } from ./state; import { parseResume } from ./nodes/parseResume; import { analyzeJD } from ./nodes/analyzeJD; import { matchGap } from ./nodes/matchGap; import { generateSuggestion } from ./nodes/generateSuggestion; import { applyEdit } from ./nodes/applyEdit; const workflow new StateGraph(ResumeState) .addNode(parseResume, parseResume) .addNode(analyzeJD, analyzeJD) .addNode(matchGap, matchGap) .addNode(generateSuggestion, generateSuggestion) .addNode(applyEdit, applyEdit) .addEdge(START, parseResume) .addEdge(parseResume, analyzeJD) .addEdge(analyzeJD, matchGap) .addEdge(matchGap, generateSuggestion) .addConditionalEdges(generateSuggestion, routeAfterSuggestion) .addEdge(applyEdit, END); export const graph workflow.compile();START和END是 LangGraph 的内置常量分别代表图的入口和出口。START连到parseResume意思是流程从解析简历开始。这里有个细节parseResume和analyzeJD其实可以并行因为它们互不依赖。LangGraph 支持并行节点但我没这么做原因是并行会让状态更新变复杂而且这两个节点都调模型并行反而可能触发 API 限流。串行虽然慢一点但稳定。4.2 条件路由用户不满意时如何回退重生成routeAfterSuggestion是条件路由函数决定生成建议之后往哪走function routeAfterSuggestion(state: typeof ResumeState.State) { if (state.userFeedback reject) { return generateSuggestion; // 用户不满意重新生成 } if (state.userFeedback accept) { return applyEdit; // 用户接受应用修改 } return END; // 等待用户输入 }这个路由实现了用户不满意就重新生成的循环。注意返回generateSuggestion会回到同一个节点形成循环。LangGraph 允许这种自循环但你要注意加循环上限不然模型一直生成不出用户满意的就会无限循环烧 token。加循环上限的做法是在状态里加个计数器retryCount: Annotationnumber({ reducer: (prev, next) next, default: () 0, }),然后在generateSuggestion节点里每次加一路由函数里判断超过 3 次就强制走applyEdit或END。我设的上限是 3实测下来 3 次还生成不出用户满意的基本是需求本身有问题再循环也没用。4.3 中断与人工介入LangGraph 的 interrupt 机制简历工具必须支持人工介入因为改得好不好是主观判断。LangGraph 提供了interrupt机制可以在某个节点后暂停等外部输入再继续。import { interrupt } from langchain/langgraph; export async function generateSuggestion(state) { // ... 生成建议逻辑 // 暂停等待用户反馈 const feedback interrupt({ type: suggestion_review, suggestions: result.suggestions, }); return { suggestions: result.suggestions, userFeedback: feedback, }; }interrupt会抛出一个特殊的中断信号图执行到这里会暂停把当前状态存起来。前端拿到建议列表展示给用户用户操作后带着反馈重新调用图从暂停的地方继续。这个机制是 LangGraph 相比手写状态机最大的优势之一。手写的话你得自己实现暂停-保存-恢复还要处理并发和状态序列化很麻烦。LangGraph 内置了这套配合它的 checkpointer 就能实现持久化的中断恢复。不过要注意interrupt需要配合checkpointer使用不然状态存不下来。checkpointer 我用的MemorySaver做开发生产环境换成了基于数据库的实现。MemorySaver 重启就丢只能开发用。5. 流式输出与前端交互的打通5.1 Next.js Route Handler 里的流式响应Agent 跑起来可能要好几十秒用户不能干等着。流式输出是必须的。Next.js 的 Route Handler 支持返回ReadableStream配合 LangGraph 的streamEvents就能实现。// app/api/agent/route.ts import { graph } from /lib/agent/graph; import { NextRequest } from next/server; export async function POST(req: NextRequest) { const { message, threadId } await req.json(); const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { const events graph.streamEvents( { messages: [{ role: user, content: message }] }, { version: v2, configurable: { thread_id: threadId } } ); for await (const event of events) { if (event.event on_chat_model_stream) { const chunk event.data.chunk?.content; if (chunk) { controller.enqueue( encoder.encode(data: ${JSON.stringify({ type: token, content: chunk })}\n\n) ); } } if (event.event on_chain_end event.name parseResume) { controller.enqueue( encoder.encode(data: ${JSON.stringify({ type: stage, stage: parsed })}\n\n) ); } } controller.close(); }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }, }); }这里用的是SSEServer-Sent Events格式每条消息以data:开头以\n\n结尾。这是浏览器原生支持的服务端推送格式比 WebSocket 简单单向推送够用了。streamEvents的version: v2是必须的v1 的事件格式不一样。我一开始没写 version事件名对不上调了半天。事件类型里on_chat_model_stream是模型逐 token 输出on_chain_end是某个节点执行完。我利用on_chain_end来推送阶段变化前端收到stage: parsed就知道简历解析完了可以更新 UI 提示。5.2 前端消费流式数据的完整实现前端消费 SSE 用fetchReadableStream读取// components/ChatPanel.tsx async function sendMessage(message: string) { const res await fetch(/api/agent, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ message, threadId }), }); const reader res.body?.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { const { done, value } await reader!.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n\n); buffer lines.pop() || ; for (const line of lines) { if (!line.startsWith(data: )) continue; const data JSON.parse(line.slice(6)); if (data.type token) { setCurrentText((prev) prev data.content); } else if (data.type stage) { setStage(data.stage); } } } }这里有个必须处理的细节SSE 的数据可能被 TCP 分片一次read()拿到的不是完整的一条消息。所以要用buffer累积按\n\n分割最后一段可能不完整留在 buffer 里等下次。我第一版没处理这个偶尔会出现 JSON 解析错误加了 buffer 之后就好了。decoder.decode(value, { stream: true })里的stream: true也很关键。多字节字符比如中文可能被分片切断不加这个参数会解码出乱码。5.3 阶段状态同步让用户知道 Agent 在干什么Agent 跑几十秒用户最怕的是不知道在干嘛。所以阶段状态同步很重要。我在状态里定义了stage字段每个节点执行完更新它前端根据stage显示不同的提示。stage 值前端显示用户感知parsing正在解析简历...知道系统在读简历parsed简历解析完成看到结构化结果analyzing正在分析岗位...知道系统在读 JDmatched匹配分析完成看到差距列表generating正在生成建议...知道系统在思考reviewing请确认修改建议可以操作了这个表是我实际用的每个阶段对应一个 UI 状态。用户看到进度在推进等待焦虑就小很多。实测下来加了阶段提示之后用户中途放弃的比例明显下降。阶段同步还有个好处是出错时好定位。如果卡在parsing不动了那肯定是简历解析节点出问题了排查范围一下就缩小了。6. 部署上线与生产环境的坑6.1 从本地到生产的配置差异本地跑通不代表生产能跑。我部署的时候踩了几个坑一个个说。第一个是环境变量。本地用.env.local生产环境我用的 Vercel要在控制台配。坑在于OPENAI_BASE_URL这种带默认值的变量本地不配也能跑走默认生产不配就报错。建议所有环境变量都显式配置别依赖默认值。第二个是超时。Vercel 的 Serverless Function 默认超时是 10 秒Hobby 计划Agent 跑一次要几十秒直接超时。解决办法是升级到 Pro 计划60 秒或者用流式响应。流式响应有个好处是只要开始返回数据连接就不会因为超时断开。我用的流式所以 Hobby 计划也能跑。第三个是冷启动。Serverless 冷启动要几秒用户第一次请求会感觉特别慢。我的做法是在页面加载时先发一个预热请求把函数唤醒。这个技巧不优雅但有效。6.2 模型调用的成本控制与限流成本这块必须算清楚。我统计过用户完整改一次简历大概要调 6-8 次模型每次平均 2000 token 输入 500 token 输出。用 gpt-4o-mini 的话一次大概 0.002 美元一天 1000 次请求就是 2 美元。听起来不多但如果被刷或者有 bug 导致循环成本会失控。我的控制措施有几个。一是循环上限前面说的 retryCount 限制 3 次。二是输入截断简历文本超过 8000 字符就截断避免超长输入。三是限流用 Next.js 的 middleware 做了个简单的 IP 限流每分钟最多 10 次请求。// middleware.ts const rateLimit new Mapstring, { count: number; reset: number }(); export function middleware(req: NextRequest) { const ip req.ip || unknown; const now Date.now(); const record rateLimit.get(ip); if (!record || now record.reset) { rateLimit.set(ip, { count: 1, reset: now 60000 }); return NextResponse.next(); } if (record.count 10) { return new NextResponse(Too many requests, { status: 429 }); } record.count; return NextResponse.next(); }这个限流是内存版的Serverless 环境下每个实例独立不够精确但能挡住大部分滥用。要精确限流得上 Redis看你的规模决定。6.3 简历数据的安全处理与隐私边界简历包含大量个人隐私信息这块必须谨慎。我的处理原则是最小化存储 及时清理。具体做法简历原文只在内存里处理不落库。解析后的结构化数据如果用户不保存请求结束就丢。如果用户选择保存只存脱敏后的版本去掉手机号、邮箱等敏感字段而且给用户明确的删除入口。模型调用这块我用的是 API 模式数据会发给模型服务商。这一点必须在隐私政策里写清楚让用户知情。如果对隐私要求极高可以考虑本地部署模型但成本和效果要权衡。注意简历数据涉及个人信息处理时务必遵守相关法律法规做好用户告知和授权。不要为了功能便利而过度收集或存储用户数据。还有个细节是日志。调试的时候很容易把简历内容打进日志生产环境这是大忌。我在日志里对简历内容做了脱敏只记录长度和结构不记录具体内容。7. 几个让我印象深刻的调试案例7.1 状态丢失为什么节点间数据传不过去有一次遇到个诡异的问题parseResume节点明明返回了parsedResume但matchGap节点里读到的却是null。查了半天发现是状态字段没在 Annotation.Root 里声明。LangGraph 的状态是严格声明的你返回一个没在 schema 里定义的字段它会被静默丢弃。我一开始以为返回什么就存什么结果不是。这个坑很隐蔽因为不报错只是数据没了。解决办法就是确保所有要传递的字段都在Annotation.Root里声明。我后来养成了习惯加新字段先改 state.ts再写节点逻辑。7.2 流式中断SSE 连接被意外关闭的排查流式输出偶尔会中途断掉前端收到一半就没数据了。排查发现是反向代理的缓冲。Vercel 的边缘网络会对响应做缓冲如果响应头没设置对它会等整个响应完成才一次性返回流式就失效了。解决办法是在响应头里加X-Accel-Buffering: no明确告诉代理不要缓冲。另外Cache-Control: no-cache也要加避免被缓存。headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no, },这个坑我查了挺久因为本地开发环境没有代理流式是正常的一部署就出问题。后来才意识到是代理层的锅。7.3 模型幻觉简历里凭空多出来的经历最严重的一次 bug 是模型在修改简历时编造了一段工作经历。用户简历里没有这段模型觉得加上会更好就加上了。这种幻觉在简历场景是灾难性的。排查下来问题出在 Prompt 上。我当时的 Prompt 是优化这份简历让它更有竞争力这个指令太开放模型就自由发挥了。改成只修改用户指定的部分不得新增任何原文没有的经历之后幻觉基本消失。另外我加了一道校验修改后的简历和原文做对比如果新增了原文没有的公司名或时间段就拦截并重新生成。这个校验用简单的字符串匹配就能做成本很低但很有效。function validateNoHallucination(original: string, revised: string) { const originalCompanies extractCompanies(original); const revisedCompanies extractCompanies(revised); const newCompanies revisedCompanies.filter(c !originalCompanies.includes(c)); if (newCompanies.length 0) { throw new Error(检测到编造的公司${newCompanies.join(, )}); } }这个校验函数是我从踩坑里总结出来的强烈建议加上。模型幻觉防不住但可以检测和拦截。8. 后续可以继续深挖的方向这套东西跑通之后我还在继续迭代。有几个方向我觉得挺有价值分享给想深入的同学。第一个是多岗位对比。现在只能对比一个岗位用户经常想同时看自己适合哪几个岗位。这个扩展需要在状态里支持多个 JD匹配节点改成循环处理。LangGraph 的SendAPI 支持动态并行适合这种场景。第二个是简历版本管理。用户改简历是个反复的过程需要能回退到任意版本。这个可以用 LangGraph 的 checkpointer 实现每个版本存一个 checkpoint用户选择回退到哪个。第三个是面试问题预测。基于简历和岗位预测面试官可能问什么。这个可以作为 Agent 的一个新分支在matchGap之后加一个predictQuestions节点。第四个是本地模型部署。如果对隐私要求高可以把模型换成开源的本地部署。LangChain 支持多种模型后端切换成本不高但效果和成本要重新评估。我个人在实际操作中的体会是Agent 架构的价值不在于用了多先进的框架而在于它把复杂任务拆成了可管理、可调试、可回退的步骤。简历工具只是其中一个应用场景这套状态图 节点 条件路由的思路放到任何多步骤 AI 任务里都适用。真正难的不是写代码是想清楚每个节点该做什么、状态该怎么流转、出错时怎么兜底。把这三件事想明白了代码反而是最简单的部分。
返回列表