
简历工具这个赛道表面上看已经被做烂了——各种模板站、在线编辑器、PDF导出工具一抓一大把。但真正动手做过的人都知道从用户填完信息到生成一份能直接投递的简历之间有一大段脏活累活信息结构化、措辞润色、岗位匹配、格式校验、多版本管理。这些环节如果全靠规则引擎硬编码维护成本高得离谱如果全靠大模型自由发挥输出又不可控。我这次用 Next.js LangGraph.js 搭了一套简历工具 AI Agent核心思路是把简历处理拆成一条有状态的工作流让每个节点各司其职模型只在需要创造力的地方介入。下面把整个落地过程拆开讲包括架构选型、状态设计、节点编排、流式输出、并发处理和踩过的坑。1. 为什么简历工具适合用 Agent 工作流而不是单次调用1.1 单次大模型调用在简历场景的三个硬伤最开始我也试过最省事的做法把用户输入的一坨文本直接丢给模型让它输出一份完整简历。跑了几十次之后问题暴露得很明显。第一个硬伤是不可控。简历里有些字段是绝对不能出错的比如联系方式、时间线、公司名称。模型在润色工作经历的时候很容易顺手把日期改掉或者把2021.03写成2021年3月再写成三月格式全乱。你没法通过一句 prompt 保证它永远不动这些字段。第二个硬伤是无法增量修改。用户说把第二段项目经历改得更偏数据方向单次调用只能把整份简历重新生成一遍前面已经确认好的内容可能又被改动了。用户会疯掉。第三个硬伤是没法做校验和回退。简历生成后需要检查有没有空字段、时间线是否连续、技能关键词是否覆盖目标岗位。这些校验逻辑如果塞进一次调用里模型既当运动员又当裁判结果不可信。1.2 LangGraph.js 的状态图模型解决了什么LangGraph.js 的核心价值在于它把一次调用变成了一张有向图。每个节点是一个独立的处理单元节点之间通过共享的 State 传递数据。这带来几个直接好处职责隔离解析节点只管把原始文本变成结构化 JSON润色节点只管改措辞校验节点只管挑毛病。每个节点的 prompt 可以写得非常聚焦输出稳定性大幅提升。条件分支校验不通过可以走回润色节点重试而不是从头再来。这就是所谓的反思循环。可中断可恢复LangGraph 支持 checkpoint用户中途关掉页面下次回来能从上次的节点继续不用重跑整条链路。流式可见每个节点的输出可以单独推给前端用户能看到正在解析…正在润色…正在校验…的实时进度体验比转圈圈好太多。提示LangGraph.js 和 Python 版的 LangGraph 概念一致但 JS 版在 Next.js 的 Route Handler 里跑更自然不用额外起一个 Python 服务部署链路短很多。1.3 技术栈的最终选型与理由层选型理由前端框架Next.js 14 App RouterServer Component 直出 Route Handler 做流式接口一套代码搞定Agent 编排LangGraph.js状态图模型天然适配多步骤简历处理支持条件边和 checkpoint模型通用对话模型可切换通过 LangChain 的 ChatModel 抽象层接入方便换供应商状态存储内存 可选持久化开发期用 MemorySaver生产可换数据库 checkpointer流式协议SSEServer-Sent Events比 WebSocket 轻单向推送足够用选 SSE 而不是 WebSocket是因为简历生成是典型的客户端发一次请求服务端持续推事件的场景不需要双向通信。SSE 在 Next.js 的 Route Handler 里实现简单浏览器原生 EventSource 就能接省掉一堆连接管理代码。2. 简历 Agent 的状态设计State 里到底该放什么2.1 用 Annotation 定义可合并的状态字段LangGraph.js 里 State 通过Annotation定义。这里有个关键决策哪些字段是覆盖式更新哪些是追加式更新。简历场景里原始输入、解析结果、润色结果、校验报告这几类数据都是覆盖式的后一个节点直接替换前一个节点的值。但操作日志这类字段需要追加方便排查问题。import { Annotation } from langchain/langgraph; export const ResumeState Annotation.Root({ rawInput: Annotation({ reducer: (_, next) next, default: () , }), parsedResume: Annotation({ reducer: (_, next) next, default: () null, }), polishedResume: Annotation({ reducer: (_, next) next, default: () null, }), validationReport: Annotation({ reducer: (_, next) next, default: () null, }), retryCount: Annotation({ reducer: (_, next) next, default: () 0, }), logs: Annotation({ reducer: (prev, next) [...prev, ...next], default: () [], }), });reducer决定了新值如何合并进旧值。默认行为是覆盖但logs字段我用了追加。这个设计看起来不起眼实际调试时非常有用——你能看到每个节点往 State 里写了什么出问题时一眼定位。2.2 结构化简历的数据契约解析节点的输出必须是一个固定 schema 的对象否则后续节点没法稳定消费。我用 Zod 定义契约再通过 LangChain 的withStructuredOutput强制模型按 schema 输出。import { z } from zod; export const ResumeSchema z.object({ basics: z.object({ name: z.string(), email: z.string(), phone: z.string(), location: z.string().optional(), summary: z.string().optional(), }), experiences: z.array(z.object({ company: z.string(), title: z.string(), startDate: z.string(), endDate: z.string().optional(), highlights: z.array(z.string()), })), projects: z.array(z.object({ name: z.string(), role: z.string().optional(), description: z.string(), highlights: z.array(z.string()), })), skills: z.array(z.string()), education: z.array(z.object({ school: z.string(), degree: z.string(), startDate: z.string(), endDate: z.string().optional(), })), });这里有个经验schema 不要设计得太细。我一开始把highlights拆成动作对象结果三个字段结果模型经常填不满反而增加了校验负担。后来改成字符串数组让模型自由发挥校验节点再去做质量判断效果好很多。2.3 状态在节点间的流转规则整条链路的状态流转是这样的rawInput进入解析节点产出parsedResume润色节点读parsedResume产出polishedResume校验节点读polishedResume产出validationReport。如果校验不通过且retryCount小于阈值条件边把流程导回润色节点同时retryCount加一。注意retryCount一定要设上限我设的是 2。超过 2 次还校验不过说明要么是模型能力问题要么是输入本身有硬伤继续重试只是烧 token。这时候应该把问题抛给用户让他补充信息。3. 节点编排解析、润色、校验三段的实现细节3.1 解析节点把自由文本变成结构化数据解析节点的输入是用户粘贴的原始简历文本可能是从旧简历复制的格式乱七八糟。这个节点的 prompt 核心是只做信息抽取不做任何改写。import { ChatPromptTemplate } from langchain/core/prompts; const parsePrompt ChatPromptTemplate.fromMessages([ [system, 你是一个简历信息抽取器。从用户提供的文本中抽取结构化信息。 规则 1. 只抽取原文中明确存在的信息不要编造。 2. 日期统一格式化为 YYYY-MM。 3. 如果某个字段原文没有留空字符串或空数组。 4. 不要改写任何措辞原样保留。], [human, {rawInput}], ]); async function parseNode(state) { const model getChatModel().withStructuredOutput(ResumeSchema); const chain parsePrompt.pipe(model); const result await chain.invoke({ rawInput: state.rawInput }); return { parsedResume: result, logs: [parse: extracted ${result.experiences.length} experiences], }; }实测下来withStructuredOutput配合 Zod schema 的稳定性远高于让模型输出 JSON 字符串再手动 parse。后者经常遇到模型在 JSON 外面包一层 markdown 代码块或者漏个逗号处理起来很烦。3.2 润色节点只在允许的字段上做增强润色节点是最容易出问题的地方。我的做法是白名单机制只有summary、highlights、description这几类字段允许改写basics、日期、公司名、学校名一律不动。const polishPrompt ChatPromptTemplate.fromMessages([ [system, 你是一个资深简历顾问。对简历中的描述性内容进行润色。 严格规则 1. 绝对不修改姓名、联系方式、公司名、学校名、职位名、日期。 2. 只润色 summary、highlights、description 字段。 3. 润色方向动词开头、量化结果、突出影响。 4. 保持原意不要添加原文没有的事实。 5. 每条 highlight 控制在 1-2 行。], [human, 以下是结构化简历\n{resume}\n\n请输出润色后的完整简历 JSON。], ]);这里的关键是把不能改什么写得比要改什么更清楚。模型对禁令的遵守程度取决于禁令的具体程度。不要修改重要字段这种模糊表述没用必须逐个列出来。3.3 校验节点用规则模型双层校验校验节点我做了两层。第一层是纯代码的规则校验检查必填字段、日期格式、时间线连续性。第二层是模型校验检查内容质量比如 highlight 是否以动词开头、是否含量化数据。function ruleValidate(resume) { const issues []; if (!resume.basics.email) issues.push(缺少邮箱); if (!resume.basics.phone) issues.push(缺少电话); if (resume.experiences.length 0) issues.push(缺少工作经历); const dateRegex /^\d{4}-\d{2}$/; for (const exp of resume.experiences) { if (!dateRegex.test(exp.startDate)) { issues.push(工作经历日期格式错误: ${exp.company}); } } return issues; }规则校验跑得快、零成本能拦掉大部分低级问题。模型校验只处理规则覆盖不到的质量维度。两层分开的好处是规则问题不需要烧 token模型只做它擅长的事。3.4 条件边与重试循环的接线方式import { StateGraph, END } from langchain/langgraph; const workflow new StateGraph(ResumeState) .addNode(parse, parseNode) .addNode(polish, polishNode) .addNode(validate, validateNode) .addEdge(__start__, parse) .addEdge(parse, polish) .addEdge(polish, validate) .addConditionalEdges(validate, (state) { const hasBlockingIssue state.validationReport?.blocking?.length 0; if (hasBlockingIssue state.retryCount 2) return polish; return END; }); export const resumeAgent workflow.compile({ checkpointer: new MemorySaver() });条件边的判断函数要尽量简单只做路由决策不要在里面做复杂计算。复杂逻辑放在节点里判断函数只读 State 里的标志位。4. 在 Next.js 里跑流式 AgentRoute Handler 的写法4.1 用 SSE 把节点进度推给前端Next.js App Router 的 Route Handler 支持返回ReadableStream正好用来做 SSE。LangGraph.js 的streamEvents或stream方法可以拿到每个节点的输出事件。// app/api/resume/route.js export async function POST(req) { const { rawInput, threadId } await req.json(); const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { const send (event, data) { controller.enqueue( encoder.encode(event: ${event}\ndata: ${JSON.stringify(data)}\n\n) ); }; try { const events resumeAgent.stream( { rawInput }, { configurable: { thread_id: threadId } } ); for await (const chunk of events) { for (const [nodeName, output] of Object.entries(chunk)) { send(node, { node: nodeName, output }); } } send(done, { ok: true }); } catch (err) { send(error, { message: err.message }); } finally { controller.close(); } }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }, }); }注意Connection: keep-alive在某些部署环境下会被平台覆盖如果发现流被提前切断先检查部署平台的超时配置而不是怀疑代码。4.2 前端消费流并渲染节点状态前端用fetchReadableStream手动解析 SSE比EventSource灵活因为EventSource只支持 GET 请求而我们需要 POST 传数据。async function runAgent(rawInput) { const res await fetch(/api/resume, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ rawInput, threadId: crypto.randomUUID() }), }); 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 parts buffer.split(\n\n); buffer parts.pop(); for (const part of parts) { const lines part.split(\n); const eventLine lines.find((l) l.startsWith(event: )); const dataLine lines.find((l) l.startsWith(data: )); if (!eventLine || !dataLine) continue; const event eventLine.slice(7); const data JSON.parse(dataLine.slice(6)); handleEvent(event, data); } } }这里有个坑SSE 的消息以\n\n分隔但网络传输是分片的一个消息可能被拆到两次read()里。所以必须维护一个buffer每次只处理完整的消息最后一段留在 buffer 里等下次。我第一版没做这个处理偶尔会 JSON.parse 报错排查了半天才发现是分片问题。4.3 用 thread_id 实现断点续跑LangGraph 的 checkpointer 会按thread_id保存每个节点的状态。用户刷新页面后只要带着同一个thread_id重新请求就能从上次中断的节点继续。前端把thread_id存在localStorage里配合一个继续上次编辑的入口体验很顺。const config { configurable: { thread_id: savedThreadId } }; const state await resumeAgent.getState(config); if (state.next.length 0) { // 有未完成的节点可以继续 }5. 并发与性能简历 Agent 扛并发的几个关键点5.1 无状态 Route Handler 外部状态存储Next.js 的 Route Handler 在 Serverless 环境下是无状态的每个请求可能落在不同实例上。所以 Agent 的 checkpointer 不能只用内存生产环境必须换成外部存储比如数据库。开发期用MemorySaver没问题但上线前一定要换掉否则用户刷新后状态就丢了。5.2 模型调用的超时与重试策略模型调用是整条链路里最慢、最不稳定的环节。我给它包了一层超时和重试async function withRetry(fn, { retries 2, timeout 30000 } {}) { for (let i 0; i retries; i) { try { return await Promise.race([ fn(), new Promise((_, reject) setTimeout(() reject(new Error(timeout)), timeout) ), ]); } catch (err) { if (i retries) throw err; await new Promise((r) setTimeout(r, 500 * (i 1))); } } }超时设 30 秒重试 2 次退避用 500ms 递增。实测下来大部分失败是网络抖动重试一次就能成功。但要注意重试只对幂等操作安全。解析和润色是幂等的同样的输入产出同样的结果可以重试如果某个节点有副作用比如写数据库重试前要确认不会重复写入。5.3 限制单用户并发与队列化简历生成一次要跑好几个模型调用单个用户如果狂点重新生成很容易把配额打满。我在前端做了按钮防抖后端用thread_id做去重——同一个thread_id如果已有正在跑的流程新请求直接返回处理中。const runningThreads new Set(); if (runningThreads.has(threadId)) { return Response.json({ error: 该任务正在处理中 }, { status: 429 }); } runningThreads.add(threadId); try { // ... 跑 agent } finally { runningThreads.delete(threadId); }提示这个Set在 Serverless 多实例下不准确只能作为单实例的粗粒度保护。真正要精确控制得用 Redis 之类的共享存储做分布式锁。5.4 流式输出对并发体验的改善流式输出不只是体验好它实际上降低了单请求的感知延迟。用户看到第一个节点输出后心理上就认为请求已经成功了不会因为等待而重复点击。这间接减少了并发压力。我对比过改成流式之后重复提交率下降了大概七成。6. 实测踩过的坑与排查过程6.1 结构化输出偶发 schema 校验失败现象解析节点大约每 20 次里有 1 次抛 schema 校验错误报某个字段类型不对。排查把失败时的原始模型输出打出来看发现模型偶尔会把highlights输出成一个字符串而不是数组比如highlights: 负责了A项目。根因schema 里highlights是z.array(z.string())但模型在只有一条 highlight 时倾向于输出字符串。这是模型的省事倾向。修复在 schema 上加.transform()把字符串自动包成数组。highlights: z.union([z.string(), z.array(z.string())]) .transform((v) Array.isArray(v) ? v : [v])这个 transform 在解析阶段做后续节点拿到的永远是数组不用各自处理兼容逻辑。6.2 润色节点把日期改乱了现象用户反馈润色后工作经历的结束日期变成了至今但原文写的是具体日期。排查对比润色前后的 JSON发现模型把endDate: 2023-06改成了endDate: 至今。原因是 prompt 里说了突出当前在职状态模型自作主张把日期改了。根因prompt 里的正向引导和禁令冲突了。模型优先执行了突出在职状态这个看起来更有用的指令。修复把禁令提到 prompt 最前面并且加了一句如果发现自己在修改日期字段立即停止并保持原值。同时在校验节点加了一条规则润色前后的日期字段必须完全一致不一致就回退。function dateIntegrityCheck(before, after) { const issues []; for (let i 0; i before.experiences.length; i) { const b before.experiences[i]; const a after.experiences[i]; if (b.startDate ! a.startDate || b.endDate ! a.endDate) { issues.push(日期被篡改: ${b.company}); } } return issues; }这个润色前后对比校验的思路很值得推广——凡是模型不该改的字段都在校验节点做一次 diff比单纯靠 prompt 约束可靠得多。6.3 SSE 流在部署后被缓冲现象本地开发时流式输出正常部署到线上后变成一次性全部返回进度条卡住然后突然完成。排查抓包发现响应头里少了X-Accel-Buffering: no中间层把流缓冲了。修复在响应头里加上headers: { Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, X-Accel-Buffering: no, }no-transform防止中间层压缩或改写响应体X-Accel-Buffering: no告诉反向代理不要缓冲。这两个头加上之后流式输出恢复正常。6.4 重试循环导致的 token 消耗失控现象某次测试发现一个请求消耗的 token 是正常情况的 5 倍。排查看日志发现校验节点连续 3 次判定不通过触发了 2 次重试每次重试都重新跑了一遍润色节点而润色节点的输入是整份简历token 消耗自然翻倍。根因重试粒度太粗。校验失败可能只是某一条 highlight 有问题但重试把整份简历都重新润色了。修复把校验报告细化到字段级别重试时只把有问题的字段传给润色节点。function buildRetryPayload(resume, report) { const fieldsToFix report.issues.map((i) i.field); return { resume, focusFields: fieldsToFix, }; }润色节点的 prompt 里加上只修改 focusFields 列出的字段其他字段原样返回。这样重试的 token 消耗降到了原来的三分之一左右。7. 简历 Agent 还能往哪些方向扩展7.1 岗位匹配把 JD 也纳入状态图现在的链路只处理简历本身。一个自然的扩展是加一个岗位匹配节点用户贴入目标岗位的 JD节点分析 JD 里的关键词和简历里的技能做匹配输出匹配度报告和优化建议。这个节点可以插在校验之后作为一条并行的分支。7.2 多版本管理用 checkpointer 做版本快照LangGraph 的 checkpointer 天然支持状态快照。每次用户确认一个版本就记录一个 checkpoint。用户可以在版本之间切换、对比、回滚。这比自己在数据库里存多份 JSON 优雅得多因为 checkpointer 存的是完整的状态图快照回滚时连中间状态都能恢复。7.3 导出环节把结构化数据渲染成 PDF最后一步是导出。我的做法是用 React 组件渲染简历再用react-pdf/renderer或 Puppeteer 转 PDF。因为简历已经是结构化的 JSON渲染组件只需要消费数据不用关心数据从哪来。这样导出环节和 Agent 链路完全解耦换模板只改组件不动 Agent。import { Document, Page, Text, View } from react-pdf/renderer; function ResumeDocument({ resume }) { return ( Document Page sizeA4 style{{ padding: 40 }} View Text style{{ fontSize: 20 }}{resume.basics.name}/Text Text{resume.basics.email} | {resume.basics.phone}/Text /View {resume.experiences.map((exp, i) ( View key{i} style{{ marginTop: 16 }} Text style{{ fontWeight: bold }}{exp.company} - {exp.title}/Text Text{exp.startDate} ~ {exp.endDate || 至今}/Text {exp.highlights.map((h, j) ( Text key{j}• {h}/Text ))} /View ))} /Page /Document ); }7.4 把校验规则做成可配置现在校验规则是硬编码在代码里的。如果要做成产品应该把规则抽出来做成配置让用户自己决定哪些字段必填highlight 最少几条要不要检查时间线连续性。规则配置化之后同一套 Agent 能适配不同行业、不同级别的简历要求。8. 一些不那么显然的经验8.1 prompt 里的禁令要具体到字段名不要修改重要信息这种话模型基本当耳旁风。有效的写法是不要修改 basics.name、basics.email、experiences[].company、experiences[].startDate。字段名越具体模型越不敢乱动。我甚至会把 schema 的字段路径直接贴进 prompt 里。8.2 校验节点要能区分阻断性问题和建议性问题不是所有校验失败都需要重试。缺邮箱是阻断性的必须让用户补某条 highlight 不够量化是建议性的可以提示但不阻断。我在校验报告里用blocking和suggestions两个数组分开条件边只看blocking。这样避免了因为一条建议性问题就触发整轮重试。8.3 流式事件要带节点名前端才能做进度映射一开始我只推数据不推节点名前端没法知道当前是哪个阶段。后来改成每个事件都带node字段前端就能把节点名映射成解析中/润色中/校验中的文案进度条也能按节点数算百分比。8.4 开发期把每个节点的输入输出都打日志LangGraph 的 State 里我专门留了logs字段做追加式记录。每个节点进来先打一条enter出去打一条exit 关键指标。调试的时候直接看 logs 数组比翻控制台快得多。上线后可以把 logs 关掉或者只保留 error 级别。8.5 模型选型不要一步到位我一开始想用最强的模型跑所有节点后来发现解析和校验这种偏确定性的任务用便宜的小模型完全够用只有润色节点需要强模型。按节点分配模型成本能降一半以上。LangGraph 的每个节点可以独立指定模型这个灵活性要用起来。整套东西跑下来最深的体会是Agent 的可靠性不来自模型本身而来自工作流的设计。把任务拆得足够细每个节点的职责足够单一再配上规则校验兜底整体稳定性就能上一个台阶。模型只负责它真正擅长的那部分——语言的组织和润色剩下的交给确定性的代码。这个边界划清楚了简历工具这种既要准确又要好看的场景才真正跑得通。