简历工具这个赛道,表面上看已经被做烂了——各种模板站、在线编辑器、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 Router | Server Component 直出 + Route Handler 做流式接口,一套代码搞定 |
| Agent 编排 | LangGraph.js | 状态图模型天然适配多步骤简历处理,支持条件边和 checkpoint |
| 模型 | 通用对话模型(可切换) | 通过 LangChain 的 ChatModel 抽象层接入,方便换供应商 |
| 状态存储 | 内存 + 可选持久化 | 开发期用 MemorySaver,生产可换数据库 checkpointer |
| 流式协议 | SSE(Server-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 里跑流式 Agent:Route 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 前端消费流并渲染节点状态
前端用fetch+ReadableStream手动解析 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 size="A4" 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 的可靠性不来自模型本身,而来自工作流的设计。把任务拆得足够细,每个节点的职责足够单一,再配上规则校验兜底,整体稳定性就能上一个台阶。模型只负责它真正擅长的那部分——语言的组织和润色,剩下的交给确定性的代码。这个边界划清楚了,简历工具这种"既要准确又要好看"的场景,才真正跑得通。