ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用LangGraph.js和Next.js构建AI简历工具:从聊天框到状态机工作流

用LangGraph.js和Next.js构建AI简历工具:从聊天框到状态机工作流 先说结论如果你打算做一个能真正干活的 AI 简历工具别再想着一个 prompt 包打天下了。我前后做了三个版本的简历助手第一个版本就是一个聊天框加大模型调用用户上传简历问一句帮我改改效果说实话也能用但完全是黑盒——用户不知道它在干什么出错了也不知道怎么纠正。直到我用 LangGraph.js 把整个流程拆成状态机再用 Next.js 把前后端串起来这个工具才真正像一个工具而不是一个话痨。这篇文章会把第四版的完整落地过程摊开来讲包括选型理由、状态机设计、API 对接、流式输出、并发处理以及实际部署后踩到的各种坑。适合的人群是已经会 Next.js 基础、想给项目接入 Agent 工作流的开发者或者正在纠结聊天式 AI 到底怎么产品化的人。里面的代码不是我为了写文章现造的都是从线上项目里扒出来的真实片段你可以直接照着抄。1. 为什么是 Next.js LangGraph.js简历工具的本质是流程不是聊天1.1 简历工具和通用聊天助手完全是两种东西很多人第一次做简历工具下意识就做成一个聊天框用户把简历贴进去说一句帮我优化一下然后等模型回复一大段。我第一个版本就是这么干的做完发现几个问题一是用户不知道模型有没有理解他的简历结构二是模型经常把建议说得天花乱坠但用户根本不知道改哪一段、怎么改三是同一个用户反复上传简历每次结果都不一样完全没有一致性。简历工具本质上是一条流水线解析简历文本、结构化提取信息、和岗位描述做匹配、分维度打分、给出可执行建议、甚至直接改写经历描述。每个环节的输入输出都是确定的环节之间有先后关系有的环节还需要根据中间结果决定走哪个分支。这种场景用单个 prompt 去承载等于把所有逻辑都堆在模型的一次推理里既不稳定也不可控。我当时列了一个对比表给自己理清思路方案核心思路优势致命短板单 Prompt 直出所有逻辑交给一次模型调用开发最快demo 效果惊艳结果不可控、不可观测、无法局部重试多步 Chain用代码串联固定步骤流程固定部分可控分支和循环逻辑要靠胶水代码硬写图状态机 Agent节点 条件边 共享状态可观测、可分支、可重试学习成本稍高概念需要理解我最终选择了第三种也就是 LangGraph.js 这套图状态机的思路。原因很朴素简历处理天然是多步骤、有分支、状态共享的场景。1.2 LangGraph.js 到底解决了什么LangGraph.js 是 LangChain 官方推出的 TypeScript 版图编排框架核心抽象只有三个状态State、节点Node、边Edge。我把简历流程想清楚之后用这三个概念就能完整表达整条流水线不需要自己去写状态管理和分支调度。对我这种长期写业务代码的人来说LangGraph.js 最实用的几个点状态是显式的每一步处理完往状态里写东西下一步从状态里读。调试的时候把状态打出来肉眼就能定位是哪一步出了问题。条件边可以直接表达如果没传岗位描述就先去问用户这种分支逻辑不用自己写 if/else 去控制外部流程。内置了流式输出streamMode一开每一步的中间结果都能实时推给前端用户体验一下子从转圈等结果变成看着 Agent 干活。有 checkpoint 机制可以保存每个会话的执行快照用户中断后还能恢复这在长流程里非常有用。还有一个很多人忽略的点LangGraph.js 是 TypeScript 写的和 Next.js 天然同构。前端、后端、Agent 逻辑全是一套类型系统接口之间不需要手写文档对齐。我团队里前后端两个人用这玩意儿基本不需要互相等人。1.3 Next.js 在架构里的位置我选 Next.js 不是因为它花哨而是因为它把API 服务 前端页面 部署三件事合并成了一件事。简历工具需要的功能Next.js 全都覆盖app/api路由直接承载 Agent 的调用入口不需要单独再起一个后端服务。Route Handlers 原生支持ReadableStream正好对接 LangGraph.js 的流式输出SSE 实现起来非常顺手。文件上传可以用server actions或者标准表单处理配合Route Handlers很容易做大小限制和格式校验。全站 TypeScriptAgent 的状态类型和前端组件的 props 类型可以共享。当然 Next.js 部署到 serverless 环境上有超时和 CPU 限制这个我在后面第五章会专门讲它是我上线过程中踩得最深的一个坑。2. 先把状态机画明白简历 Agent 的节点、状态与条件分支2.1 状态定义从原始文件到结构化报告我不想一上来就写代码。实话说LangGraph.js 的项目里80% 的坑都出在没把状态定义想清楚就开写。状态就是整个 Agent 的共享内存每个节点往里写数据后面的节点读数据。简历工具的状态我最终设计成下面这个结构import { Annotation } from langchain/langgraph; export const ResumeState Annotation.Root({ // 用户输入 resumeRawText: Annotationstring, jdText: Annotationstring, // 解析结果 parsedResume: Annotation{ sections: ResumeSection[]; skills: string[]; experience: ExperienceItem[]; education: EducationItem[]; contact: ContactInfo; } | null, // 匹配与评分 matchReport: Annotation{ matchedKeywords: string[]; missingKeywords: string[]; scoreByDimension: Recordstring, number; } | null, // 输出内容 suggestions: Annotationstring[], rewrittenBlocks: Annotation{ original: string; rewritten: string; reason: string }[], // 流程控制 errors: Annotationstring[], needsClarification: Annotationboolean, });每个字段用Annotation包裹LangGraph.js 会把所有节点写入的值合并到这个状态对象里。需要注意初始状态里那些可空字段设成null节点里要处理空值不然 TypeScript 会一直报错。我踩过一个具体的坑一开始我把suggestions设计成普通字符串后来发现一个节点想追加建议另一个节点想基于建议继续改写两个节点同时操作同一个字段状态就被覆盖了。LangGraph.js 允许给字段配 reducer比如Annotationstring[]({ reducer: (a, b) [...(a ?? []), ...(b ?? [])] })但我建议新人一开始别急着玩 reducer把字段拆开、职责单一比什么 reducer 都好使。2.2 节点划分解析、匹配、评分、改写各干一件事有了状态之后节点划分就很自然了。我的 Agent 有五个核心节点parse_resume 节点把用户上传的简历文本解析成结构化数据。这里我不用模型做全部工作因为模型在结构提取上会有幻觉我用了正则先粗分 模型再精修的组合。先按常见的简历标题工作经历教育背景项目经验把文本切成块然后调用一次模型把每个块里的要点提取出来。match_jd 节点把解析出来的技能和经历跟岗位描述做匹配。这里我要求模型输出三个东西命中的关键词、缺失的关键词、每项匹配的证据原文引用。证据非常重要是后面防止幻觉的基础。score_resume 节点分维度打分。我固定了六个维度匹配度、结构清晰度、成果量化程度、技术栈完整性、项目深度、表达专业性。这个节点不直接生成建议只输出分数和扣分原因。generate_suggestions 节点根据扣分原因生成可执行建议。这个节点必须引用score_resume里输出的扣分原因不允许自己编理由。rewrite_bullets 节点用户确认某段经历描述后做局部改写输出原文对照、改写结果、改写理由。节点的职责单一之后好处非常明显当用户说你这段建议不靠谱我可以只重跑generate_suggestions不用整个流程都重新来一遍。2.3 条件边什么时候追问什么时候直接出报告图状结构相比线性链的优势就是分支。我的图里有三个条件分支import { StateGraph, START, END } from langchain/langgraph; const graph new StateGraph(ResumeState) .addNode(parse_resume, parseResumeNode) .addNode(match_jd, matchJdNode) .addNode(score_resume, scoreResumeNode) .addNode(generate_suggestions, generateSuggestionsNode) .addNode(rewrite_bullets, rewriteBulletsNode) .addEdge(START, parse_resume) .addConditionalEdges(parse_resume, routeAfterParse) .addConditionalEdges(match_jd, routeAfterMatch) .addConditionalEdges(score_resume, routeAfterScore) .addEdge(rewrite_bullets, END); function routeAfterParse(state: typeof ResumeState.State) { // 解析失败或者信息太少先不急着匹配走追问分支 if (state.errors.length 0 || !state.parsedResume) { return ask_for_clarification; } return match_jd; } function routeAfterMatch(state: typeof ResumeState.State) { // 用户没提供岗位描述那就先出简历本身的诊断 if (!state.jdText.trim()) { return score_resume_no_jd; } return score_resume; } function routeAfterScore(state: typeof ResumeState.State) { // 分数低于阈值时专门走一轮补强建议分支 if (state.matchReport?.scoreByDimension[匹配度] 60) { return generate_suggestions_strict; } return generate_suggestions; }分支的名字虽然叫ask_for_clarification、score_resume_no_jd、generate_suggestions_strict但这些节点实际指向的还是那五个核心节点只是图里多画了几条边。LangGraph.js 允许同一个节点被多条边指向这么做的好处是流程逻辑全在图定义里一眼就能看懂什么时候走什么路径。我特别想强调一点条件边的判断逻辑里不要依赖大模型输出做判断要依赖结构化字段。比如用户有没有提供岗位描述这个判断直接看state.jdText.trim()就行别让模型去总结用户是否表达了岗位意向那会引入不确定性。图编排层要的是确定性不确定性留给模型去处理内容。3. 核心代码落地Next.js API 路由如何驱动 LangGraph.js 工作流3.1 项目初始化和依赖安装这个项目我建议直接用 Next.js 的 App RouterTypeScript 模板省得后面自己配。npx create-next-applatest resume-agent --typescript --app --src-dir cd resume-agent npm install langchain/langgraph langchain/openai pdf-parse mammoth npm install zod依赖说明一下langchain/openai是 LangChain 的 OpenAI 模型封装你如果要用 Anthropic 或者国产模型换对应的包就行图结构完全不用动。pdf-parse和mammoth分别处理 PDF 和 Word 文档。zod用来做模型输出的结构校验这个在后面防幻觉章节会细说。环境变量方面.env.local里放模型供应商的 key以及一个可选的基础模型名字我习惯让modelName可配置方便在不同模型之间切换对比效果。3.2 把 Agent 封装成一个可调用的服务函数不要把图对象直接暴露给路由层。我单独建了一个lib/resume-agent.ts导出两个函数一个是单次执行的runResumeWorkflow一个是流式执行的streamResumeWorkflow。路由层只认识这两个函数不需要关心图是怎么编译的。// lib/resume-agent.ts import { HumanMessage, SystemMessage } from langchain/core/messages; import { ChatOpenAI } from langchain/openai; import { MemorySaver } from langchain/langgraph; import { graph } from ./resume-graph; const checkpointer new MemorySaver(); const compiledGraph graph.compile({ checkpointer }); export async function streamResumeWorkflow(input: { resumeRawText: string; jdText: string; sessionId: string; }) { const { resumeRawText, jdText, sessionId } input; const config { configurable: { thread_id: sessionId }, }; const initialInput { resumeRawText, jdText, parsedResume: null, matchReport: null, suggestions: [], rewrittenBlocks: [], errors: [], needsClarification: false, }; return compiledGraph.stream(initialInput, { config, streamMode: updates, }); }这里有个细节streamMode: updates返回的是每个节点执行完之后的增量状态而不是全量状态。前端拿到增量数据可以精准地知道现在跑到哪个节点了这就为进度展示提供了数据基础。如果只需要最终结果用streamMode: values会更省流量但用户体验会差很多。checkpointer 我用的是MemorySaver单机开发完全够用。但生产环境多实例部署时内存态是各实例独立的用户请求打到不同实例就找不到之前的会话了。我后来换成了基于 Postgres 的 checkpointer这个后面的章节会提。3.3 Route Handler 里的 SSE 流式响应Next.js 的 Route Handler 返回流式响应非常直接核心就是一个ReadableStream// app/api/agent/route.ts import { NextRequest } from next/server; import { streamResumeWorkflow } from /lib/resume-agent; export const dynamic force-dynamic; export const maxDuration 60; export async function POST(req: NextRequest) { const { resumeRawText, jdText, sessionId } await req.json(); if (!resumeRawText || resumeRawText.length 20) { return Response.json({ error: 简历内容太短无法解析 }, { status: 400 }); } try { const stream await streamResumeWorkflow({ resumeRawText, jdText, sessionId }); const encoder new TextEncoder(); const sseStream new ReadableStream({ async start(controller) { try { for await (const update of stream) { const payload data: ${JSON.stringify(update)}\n\n; controller.enqueue(encoder.encode(payload)); } controller.enqueue(encoder.encode(data: [DONE]\n\n)); controller.close(); } catch (err) { console.error(agent stream error:, err); controller.enqueue( encoder.encode(data: ${JSON.stringify({ error: 处理中断 })}\n\n) ); controller.close(); } }, }); return new Response(sseStream, { headers: { Content-Type: text/event-stream; charsetutf-8, Cache-Control: no-cache, no-transform, Connection: keep-alive, }, }); } catch (err) { return Response.json({ error: 简历处理启动失败 }, { status: 500 }); } }几个要注意的地方第一export const dynamic force-dynamic很重要不然 Next.js 可能把路由缓存成静态响应流就变成一次性返回了。第二maxDuration要根据模型调用时间调整在 Vercel 免费版默认只有 10 秒或者 60 秒后面说并发的时候我会展开。第三SSE 的消息格式必须是data: ...\n\n前端用原生的EventSource或者fetch读流都可以我用的是fetch因为需要传 POST bodyEventSource只支持 GET。3.4 文件上传与文本解析的坑简历工具绕不开文件解析。我第一版直接让用户粘贴文本虽然稳定但门槛太高后来加了文件上传结果踩了一堆坑。我的上传表单放在前端文件通过FormData传给独立的POST /api/upload路由这个路由负责做三件事校验文件类型和大小、提取文本、把提取结果返回给前端。为什么不让用户直接把文件内容塞进 Agent 调用因为提取文本这步是纯 CPU 操作如果放在 Agent 的工作流节点里每次重跑都要重新解析浪费且慢。// app/api/upload/route.ts import { NextRequest } from next/server; export const maxDuration 30; export async function POST(req: NextRequest) { const formData await req.formData(); const file formData.get(file) as File | null; if (!file) { return Response.json({ error: 未收到文件 }, { status: 400 }); } const ext file.name.split(.).pop()?.toLowerCase(); if (![pdf, docx, doc, txt].includes(ext ?? )) { return Response.json({ error: 不支持的文件格式 }, { status: 400 }); } if (file.size 5 * 1024 * 1024) { return Response.json({ error: 文件不能超过 5MB }, { status: 400 }); } const buffer Buffer.from(await file.arrayBuffer()); let text ; try { if (ext pdf) { const pdf require(pdf-parse); const data await pdf(buffer); text data.text; } else if (ext docx) { const mammoth require(mammoth); const data await mammoth.extractRawText({ buffer }); text data.value; } else { text buffer.toString(utf-8); } } catch (err) { return Response.json({ error: 文件解析失败请尝试转换为 PDF 后重新上传 }, { status: 422 }); } // 简单的清洗去掉多余空行和不可见字符 text text.replace(/\r/g, ).replace(/[ \t]/g, ).replace(/\n{3,}/g, \n\n).trim(); if (text.length 50) { return Response.json({ error: 未能从文件中提取到有效内容可能是扫描件 }, { status: 422 }); } return Response.json({ text }); }这里有个比较隐蔽的问题pdf-parse这个库在纯服务端 Node 运行时没问题但如果你把它放在 Edge Runtime 里跑很多 Node API 不可用直接报错。所以上传路由要显式保证运行在 Node.js 运行时Next.js 里可以在路由文件顶部加export const runtime nodejs。我在本地开发一切正常部署到 Vercel 后发现文件上传全挂查了半天才发现是 Edge Runtime 的问题。docx 解析我用mammoth它把 Word 文档转成 HTML 或者纯文本的效果都不错表格也能勉强处理。遇到拆分开的旧版.doc格式mammoth就不行了我目前直接提示用户转成 PDF 再上传成本最低。4. 前端流式交互从上传简历到逐字输出报告的完整链路4.1 进度感比速度感更重要这是我从用户反馈里悟出来的。一开始我用流式输出但只把最终报告逐字渲染用户看到的就是转圈很久然后开始一个字一个字蹦。后来我改成把 Agent 的每个节点执行状态实时渲染成步骤卡片解析中、匹配中、评分中、生成建议中每个步骤显示当前状态和耗时。用户反馈明显好了很多哪怕整体耗时没变但知道它在干什么这件事本身就极大缓解了等待焦虑。前端做这件事的数据基础就是前面说的streamMode: updates。每次推送过来的 update 长这样{ parse_resume: { parsedResume: { sections: [...] } } }每个 key 就是节点名value 就是该节点写入状态的增量。前端只需要维护一个步骤列表按节点名映射到 UI 状态。4.2 fetch 读流与打字机效果SSE 我用fetch手动消费因为要传 POST body而且要处理错误状态。核心代码不长// app/hooks/useAgentStream.ts export async function runAgentWithStream({ resumeRawText, jdText, sessionId, onUpdate, onDone, onError, }: { resumeRawText: string; jdText: string; sessionId: string; onUpdate: (nodeName: string, data: unknown) void; onDone: () void; onError: (message: string) void; }) { const response await fetch(/api/agent, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ resumeRawText, jdText, sessionId }), }); if (!response.ok || !response.body) { const errorData await response.json().catch(() ({})); onError(errorData.error ?? 请求失败); return; } const reader response.body.getReader(); const decoder new TextDecoder(); let buffer ; try { while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); // SSE 消息以 \n\n 分隔 const messages buffer.split(\n\n); buffer messages.pop() ?? ; for (const msg of messages) { if (!msg.startsWith(data: )) continue; const raw msg.slice(6).trim(); if (raw [DONE]) { onDone(); continue; } try { const update JSON.parse(raw); const nodeName Object.keys(update)[0]; if (nodeName) { onUpdate(nodeName, update[nodeName]); } } catch { // 忽略无法解析的中间帧 } } } } finally { reader.releaseLock(); } }前端拿到每个节点的数据后generate_suggestions节点的输出直接渲染成建议卡片rewrite_bullets节点的输出渲染成原文-改写-理由三栏对照每个步骤用一个小状态图标表示完成度。打字机效果我没用现成的库就是拿到一段完整文本后在一个requestAnimationFrame循环里按每帧 2-4 个字符的速度逐步追加到显示区。这里有一个容易踩的坑大模型输出可能是分多帧到达的如果每帧都重新开始打字机动画文字会跳变。解决方案是维护一个已显示文本和待显示队列新来的帧先插入队列动画循环只消费队列。4.3 会话恢复与中断处理靠 LangGraph.js 的 checkpoint我实现了刷新页面后恢复进度的功能。用户在任一环节刷新前端重新请求 Agent 时可以传入同一个sessionId如果需要从上次中断的节点继续执行就把启动输入改成上次的状态快照。这个功能听起来很酷实际实现时我在 Node 服务端维护了每个 session 的最近状态前端拉一次快照就能恢复 UI。但我必须诚实说这个功能在简历场景里使用率很低用户一般不会中途刷新。它真正有意义的场景是长任务比如一次要处理几十份简历的批量场景。如果你只是做单份简历优化MemorySaver加简单的前端重试就够了不要为了看起来完整去过度设计。5. 上线前必须处理的硬问题并发、超时与成本预算5.1 serverless 超时是你第一个敌人我在本地跑得好好的一部署到 Vercel 就遇到问题部分简历处理到一半接口直接 504。查日志发现是 serverless 函数执行超时。问题出在三个方面Vercel 免费版函数默认执行时长上限只有 10 秒现在 Hobby 是 60 秒一个完整简历流程至少调 3-4 次模型单次模型响应就可能 10-20 秒根本不够。PDF 解析这类 CPU 密集型操作在 serverless 沙箱里会被严重降速本地 200ms 的解析在沙箱里可能要 2 秒而且占用函数执行时长。模型推理时长受供应商排队影响波动极大官方的 P95 和 P99 差别能到 3 倍以上。我的应对方案分两层。第一层把maxDuration显式调大Pro 版可以到 300 秒基本够用。第二层把 Agent 调用和文件解析拆开文件解析用独立路由先跑完Agent 路由只接收纯文本这样函数时长预算几乎全花在模型调用上。第三层在代码里给每次模型调用设置硬超时用AbortSignal.timeout()包裹const controller new AbortController(); const timeout setTimeout(() controller.abort(), 25000); try { const res await model.invoke(messages, { signal: controller.signal }); clearTimeout(timeout); return res; } catch (err) { if (controller.signal.aborted) { // 记录并降级这个节点返回处理超时由条件边决定是重试还是跳过 } }超时之后的降级策略比超时本身更重要。我让每个节点捕获超时错误后在state.errors里追加一条记录条件边根据错误类型决定走重试一次还是跳过本节点直接出部分报告。比如score_resume超时我宁可跳过评分直接出建议也不让整个流程死掉。5.2 流式接口为什么天然抗并发但模型配额才是瓶颈热门搜索里总有人问AI Agent 怎么扛并发。我的实测经验是如果你的 Agent 是流式输出你的应用服务器并发压力其实不大真正的瓶颈在模型供应商的速率限制RPM/TPM。解释一下为什么。传统 API 高并发难是因为每个请求要占用一个后端线程/进程直到响应完成。而流式接口的模型调用是通过 HTTP 流逐步返回的你的服务器进程在等待模型响应时并不需要为每个请求分配一整块计算资源事件循环可以同时管理大量挂起的流。在 Node.js 里我用MemorySaver跑本地压测500 个并发 SSE 连接App 进程的 CPU 和内存都还健康瓶颈全部出现在模型供应商的限流上。真正要处理的是两个配额问题每分钟请求数RPM和每分钟 token 数TPM。这两个配额我建议在前端就做限制每个用户限制同时只能跑一个 Agent 任务而不是一股脑把所有请求都打给模型。简历工具属于低频高价值场景做并发保护的核心目的不是扛流量而是防止某个用户恶意刷接口把成本打爆。我做的限流很朴素一个简单的内存令牌桶按 sessionId 维度限制每用户同时最多 1 个任务每分钟最多 5 次调用超出直接返回 429。够用就好别一上来就上 Redis。5.3 成本预算给每个节点分配 token 额度简历 Agent 的成本大头在模型调用我算过一笔账一次完整流程大约消耗 8000-12000 token输入输出都算上换成中等价位的模型单次成本大概在几毛钱到一块钱人民币之间。如果用户量上来这数字不小所以我在工程上做了三件事一是节点分级用模型。parse_resume和match_jd用便宜的小模型比如 mini 级别的rewrite_bullets这种需要语言输出质量的节点用强模型。实测下来结构化提取这种任务小模型配合 zod 校验后效果和大模型差距不大但成本差 5 倍以上。二是控制输入 token。简历文本先清洗去掉明显无关的段落比如求职信模板、页眉页脚岗位描述截断到 2000 字以内避免用户粘贴一大段 JD 把上下文塞满。三是缓存。同一个简历 同一个岗位描述的组合在 24 小时内重复请求直接返回缓存结果。我用数据库存了一份(resumeHash, jdHash, modelVersion) → result的映射命中率大概有 15%别看比例不大白赚的。6. 真实使用中的坑解析失败、评分幻觉与上下文失控6.1 PDF 解析的隐性失败扫描件和诡异排版我上线第一周就收到用户反馈上传的简历解析出来全是乱码。排查后发现他传的是手机扫描件pdf-parse提取出来是空的因为扫描件本身没有文字层。这个问题没有完美的低成本解。纯前端做 OCR 不现实调云端 OCR 服务又引入额外成本和隐私风险。我的处理是分三层第一解析前检查提取文本长度如果小于 50 字符明确提示用户可能是扫描件或加密 PDF第二引导用户改用 Word 或纯文本上传第三在帮助文档里写清楚请确认文件可复制文字扫描件请用专业的 OCR 工具转成文本后粘贴。实测下来60% 的扫描件用户会乖乖换格式剩下的就放弃这个流失可以接受。另外还有一个排版问题有些简历用多栏布局pdf-parse提取出来文本顺序会乱一眼看过去是人话但片段之间根本没逻辑关系。我的parse_resume节点里加了一步乱序检测把提取文本按换行切成行检查相邻行的内容主题是否突变比如上一行还在写精通 React下一行突然变成2018 年毕业于某某大学就判定为疑似乱序提示用户粘贴纯文本。这个检测用正则加简单规则就能做不需要模型。6.2 评分幻觉让模型给可验证的输出这是最影响信任感的坑。最开始我的score_resume节点直接让模型给简历打分并说明理由结果模型经常编造理由比如简历中提到精通 Kubernetes但未提供具体项目——用户简历里明明写了 Kubernetes 项目。这种幻觉对简历工具是致命的因为用户会直接质疑整个工具的可信度。我的解决方案是强制模型输出证据引用。在match_jd节点里我要求模型对每一个结论都附带原文片段并且在 prompt 里明确写了规则不得输出简历原文中不存在的描述每个结论必须引用原文引用长度不超过 30 字。然后用 zod 做结构校验import { z } from zod; const MatchReportSchema z.object({ matchedKeywords: z.array(z.object({ keyword: z.string(), evidence: z.string(), // 原文证据 })), missingKeywords: z.array(z.string()), // 缺失关键词不需要证据 scoreByDimension: z.object({ matchRate: z.number().min(0).max(100), structureClarity: z.number().min(0).max(100), quantification: z.number().min(0).max(100), techDepth: z.number().min(0).max(100), projectImpact: z.number().min(0).max(100), expressionQuality: z.number().min(0).max(100), }), notes: z.array(z.string()), });解析模型输出时如果 zod 校验失败我不会返回给用户而是自动重试一次并把 上次输出格式不符合要求 作为新 prompt 里的错误提示。这种校验失败 带错误反馈重试的组合拳能把格式错误率从 15% 压到 1% 以下。评分幻觉还有一个隐蔽来源模型会给所有简历打出相似的分数因为它的训练数据里好简历和差简历的比例基本失衡。我后来在score_resume节点里不再让模型直接打分而是先让它列出简历在六个维度上的具体优势和短板再由代码根据这些具体点换算成分数。分数从模型的主观判断变成代码的规则计算稳定性高了很多。6.3 上下文窗口管理简历太长、历史消息太多怎么办很多简历工具的用户是资深工程师工作十年的简历加上项目描述轻轻松松突破 5000 字再加上岗位描述和系统提示词一次 Agent 流程的上下文很容易逼近模型窗口上限。我的处理原则是每个节点只看到自己需要的那部分状态不要让全部历史都进入每次模型调用。具体来说match_jd节点只需要parsedResume.skills和parsedResume.experience不需要完整原文rewrite_bullets节点只需要用户指定的某一段经历描述其他段落完全不放进去。LangGraph.js 的状态字段是结构化的天然支持这种按节点取子集的用法。我在每个节点函数里构建 messages 时只挑需要的字段拼 prompt而不是干巴巴地把整个 state 转成字符串。另外一个经验不要把对话历史一股脑全塞进去。简历 Agent 是任务型工作流不是对话型闲聊每个新任务都是从干净的初始状态开始历史对话只保留用户的显式修改指令比如第二段经历改成更侧重结果描述作为改写节点的附加约束。这样上下文窗口干净模型不会把前面几轮的话混进来。6.4 容易被忽略的隐私问题做简历工具绕不开隐私合规。简历是高度敏感的个人信息我在项目里做了几件起码的事情传输全程 HTTPSNext.js 部署自带、数据库里不存简历原文、文件上传后临时文件立即删除、Agent 调用的请求日志里脱敏掉姓名电话邮箱。技术上我用一个简单的 PII 检测正则做脱敏匹配到邮箱、电话号码后替换成占位符再传给模型。这个方案不完美会误伤一些文本比如项目描述里的邮箱推送系统这种词但确实能减少敏感信息外泄。如果你的简历工具要对公上线建议认真做一次隐私设计评审别等到被用户投诉了再改。我自己在本地开发时的处理方式更直接测试数据全部用自己编的虚构简历绝不用真实简历做调试。你可能觉得这是废话但我知道不少团队就是拿 CEO 的简历当测试样本的。最后说点个人体会。这个项目做下来我最大的感受是AI Agent 能不能下地干活关键在于你愿不愿意把工作流拆细。拆成图结构之后每个节点都能单独调试、单独重试、单独换模型整个系统的可靠性和可维护性完全不一样。LangGraph.js 提供的是框架但真正让简历工具好用的是我在上面做的那些工程约束结构化输出、证据引用、节点级超时、成本分级。如果你也正在做类似的 AI 工具建议先别急着写代码把状态和节点画出来再动手你会少走很多弯路。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进