简历工具这个赛道,表面上看已经被做烂了,但真正动手做一个能用的 AI Agent 版本,你会发现坑远比想象中多。我最近用 Next.js 搭配 LangGraph.js 完整落地了一套简历优化工具,从简历解析、岗位匹配、内容重写,到多轮对话式修改,整个链路跑通之后有不少值得记录的东西。这篇文章不讲空泛的概念,而是把我在实际开发中遇到的架构选型、状态管理、流式输出、并发处理这些具体问题拆开来讲,适合已经有一定前端基础、想往 AI Agent 方向落地的开发者参考。如果你正在纠结用什么样的技术栈来做这类工具,或者已经动手但卡在某个环节,下面的内容应该能帮你少走一些弯路。
1. 为什么简历工具值得用 Agent 架构重做一遍
1.1 传统简历工具的瓶颈在哪里
市面上大多数简历工具的本质是"模板填充器"——你输入信息,它套一个排版,导出 PDF。稍微进阶一点的会做一些关键词匹配,告诉你简历里缺了哪些技能词。但这类工具有一个根本性的问题:它们不理解上下文。
举个例子,一个用户写"负责公司核心业务系统的开发与维护",传统工具只能判断这句话里有没有"开发""维护"这类动词,但它无法判断这句话放在应聘"高级前端工程师"的场景下是否足够有说服力。它不知道这个岗位更看重的是性能优化经验、组件库建设能力还是跨端方案落地。这种语义层面的判断,恰恰是大语言模型擅长的事情。
而 Agent 架构相比单纯的"调一次 API 返回结果",多出来的核心能力是多步骤推理和状态保持。简历优化不是一个单轮任务,它需要:先解析简历结构,再分析目标岗位要求,然后逐段对比找出差距,接着生成修改建议,最后还要根据用户反馈迭代调整。这个流程天然适合用图结构来编排。
1.2 LangGraph.js 在浏览器端和 Node 端的定位差异
LangGraph.js 是 LangChain 团队推出的图编排框架的 JavaScript 版本。它的核心抽象是"状态图"——你定义一个共享的 State 对象,然后定义若干节点(Node)和边(Edge),每个节点读取 State、执行逻辑、返回 State 的增量更新。
这里有一个很多人容易搞混的点:LangGraph.js 可以跑在 Node.js 服务端,也可以部分跑在浏览器端,但涉及 API Key 调用的部分必须放在服务端。我的做法是把整个 Agent 编排放在 Next.js 的 Route Handler 里,前端只负责收集用户输入和展示流式结果。这样既保证了密钥安全,又能利用 Next.js 的边缘函数能力做低延迟响应。
注意:LangGraph.js 的
StateGraph在服务端和客户端的行为基本一致,但如果你用了checkpointer做持久化,浏览器端需要额外处理存储适配。我建议初期直接用内存存储,等流程稳定后再接入数据库。
1.3 这套技术栈组合的实际收益
用 Next.js + LangGraph.js 做简历工具,我实测下来最大的收益有三个:
第一,流式体验天然契合。Next.js 的 Route Handler 支持 ReadableStream,LangGraph.js 的节点可以逐步产出结果,两者结合可以让用户在等待过程中就看到 Agent 的"思考过程",而不是盯着一个 loading 转圈。
第二,状态管理清晰。简历优化涉及多个阶段的数据流转,用图结构定义之后,每个节点的输入输出一目了然,调试的时候可以单独测试某个节点,不用把整个链路跑一遍。
第三,扩展成本低。后面如果想加"模拟面试""薪资谈判建议"这些功能,只需要在图上加节点和边,不用重构整个架构。
2. 项目骨架搭建:从零到能跑通第一个节点
2.1 Next.js 项目初始化与目录约定
我用的 Next.js 15 的 App Router 模式。初始化命令很标准:
npx create-next-app@latest resume-agent --typescript --tailwind --app --src-dir目录结构上,我做了这样的约定:
src/ app/ api/ agent/ route.ts # Agent 主入口,处理流式响应 page.tsx # 前端交互页面 lib/ agent/ graph.ts # LangGraph 图定义 nodes/ # 各个节点实现 parse.ts analyze.ts rewrite.ts state.ts # State 类型定义 llm/ client.ts # 模型客户端封装这个结构的好处是 Agent 逻辑和 UI 逻辑完全分离。lib/agent下面的代码可以独立测试,不依赖 Next.js 运行时。
2.2 LangGraph.js 的安装与版本选择
npm install @langchain/langgraph @langchain/openai @langchain/core这里要特别注意版本兼容性。LangGraph.js 在 0.2.x 之后 API 有过一次比较大的调整,StateGraph的泛型定义和addNode的签名都变了。我建议直接锁定较新的稳定版本,不要混用不同大版本的@langchain/core。
{ "@langchain/langgraph": "^0.2.0", "@langchain/core": "^0.3.0", "@langchain/openai": "^0.3.0" }如果你用的是其他模型提供商,把@langchain/openai换成对应的包即可,LangGraph.js 本身不绑定具体模型。
2.3 定义第一个 State 和节点
State 是整个图的血液。我最初设计的 State 是这样的:
import { Annotation } from "@langchain/langgraph"; export const ResumeState = Annotation.Root({ rawResume: Annotation<string>(), jobDescription: Annotation<string>(), parsedSections: Annotation<ResumeSection[]>({ reducer: (prev, next) => next ?? prev, default: () => [], }), suggestions: Annotation<Suggestion[]>({ reducer: (prev, next) => [...prev, ...next], default: () => [], }), finalResume: Annotation<string>(), });这里有个关键设计决策:suggestions用了累加 reducer,因为优化建议是逐步产生的,每次节点执行都应该追加而不是覆盖。而parsedSections用覆盖 reducer,因为解析结果应该是幂等的。
第一个节点我写的是简历解析:
async function parseResume(state: typeof ResumeState.State) { const llm = getLLM(); const result = await llm.invoke([ { role: "system", content: "你是一个简历解析助手,请将简历拆分为结构化段落。" }, { role: "user", content: state.rawResume }, ]); return { parsedSections: parseLLMOutput(result.content) }; }跑通这个节点之后,你就有了一个最小可用的图。别急着加复杂逻辑,先把"输入→解析→输出"这条线走通,确认模型调用、状态传递、类型推断都没问题。
3. 核心节点设计:解析、匹配、重写三步走
3.1 简历解析节点的结构化输出策略
简历解析看起来简单,实际上是最容易出问题的环节。用户的简历格式千奇百怪,有 PDF 转出来的乱码,有表格排版的,还有中英文混排的。我试过几种方案:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 纯 Prompt 解析 | 实现快 | 格式不稳定 | 快速验证 |
| Prompt + JSON Schema | 结构可控 | 需要模型支持 | 生产环境 |
| 分段落多次调用 | 准确率高 | 成本高、慢 | 高价值场景 |
我最终选了第二种,用 OpenAI 的 Structured Output 能力,强制模型返回符合 Schema 的 JSON。LangChain.js 里可以这样写:
const parser = StructuredOutputParser.fromZodSchema( z.object({ sections: z.array(z.object({ type: z.enum(["education", "experience", "skills", "projects"]), title: z.string(), content: z.string(), bullets: z.array(z.string()), })), }) );实测下来,加了 Schema 约束之后,解析成功率从大概 70% 提升到了 95% 以上。剩下 5% 的失败案例主要是简历内容太短或者格式太离谱,这种我会在前端做一个兜底提示,让用户手动补充。
3.2 岗位匹配节点的对比逻辑
岗位匹配的核心是找出"简历里有什么"和"岗位要什么"之间的差距。我的做法是让模型做两件事:提取岗位的关键要求,然后逐条对照简历内容打分。
async function matchJob(state: typeof ResumeState.State) { const llm = getLLM(); const prompt = ` 岗位描述:${state.jobDescription} 简历内容:${JSON.stringify(state.parsedSections)} 请分析: 1. 岗位的核心要求(最多5条) 2. 简历中对应的匹配情况(完全匹配/部分匹配/缺失) 3. 每条差距的具体改进建议 `; const result = await llm.invoke(prompt); return { suggestions: parseMatchResult(result.content) }; }这里有个经验:不要让模型直接输出"匹配度百分比",那个数字没有意义且不稳定。让模型输出具体的匹配条目和理由,前端再根据条目数量做一个粗略的进度展示,用户感知反而更好。
3.3 内容重写节点的风格控制
重写节点是最考验 Prompt 工程的地方。同一个意思,写成"负责了XX系统的开发"和"主导XX系统架构设计,支撑日均百万级请求",给人的感觉完全不同。
我在重写节点里加了几个控制维度:
- 动词强度:根据用户在团队中的实际角色选择合适的动词,不要过度包装
- 量化程度:有数据的保留数据,没数据的引导用户补充
- 行业术语:根据目标岗位所在行业调整用词
const rewritePrompt = ` 原始内容:${section.content} 目标岗位:${state.jobDescription} 重写要求: - 保持事实不变,只优化表达 - 使用${verbStyle}风格的动词 - 如果原文有数字,必须保留 - 不要编造任何原文没有的经历 `;提示:重写节点一定要加"不要编造"的约束。我早期版本没加这条,模型会给用户凭空加上"带领10人团队"这种经历,用户如果直接用了,面试时会非常尴尬。
4. 流式输出与前端交互的工程细节
4.1 Next.js Route Handler 中的流式响应实现
LangGraph.js 支持streamEvents和stream两种流式模式。我推荐用streamEvents,因为它能区分不同节点的事件,前端可以据此展示"正在解析简历""正在匹配岗位"这样的阶段提示。
export async function POST(req: Request) { const { resume, jobDescription } = await req.json(); const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { const events = graph.streamEvents( { rawResume: resume, jobDescription }, { version: "v2" } ); for await (const event of events) { if (event.event === "on_chain_end" && event.name === "parseResume") { controller.enqueue(encoder.encode(`data: ${JSON.stringify({ stage: "parsed", data: event.data.output })}\n\n`)); } } controller.close(); }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", }, }); }这里用的是 SSE(Server-Sent Events)格式。相比 WebSocket,SSE 在 Next.js 里实现更简单,而且对于这种单向推送场景完全够用。
4.2 前端如何优雅地展示 Agent 的中间状态
前端我用了一个简单的状态机来管理展示:
const [stage, setStage] = useState<"idle" | "parsing" | "matching" | "rewriting" | "done">("idle"); const [partialResults, setPartialResults] = useState<Record<string, unknown>>({});每收到一个 SSE 事件,就更新对应的 stage 和 partialResults。UI 上用一个步骤条展示当前进度,已经完成的步骤可以点击查看中间结果。
这个设计的好处是用户不会觉得"卡住了"。即使整个流程要跑 20 秒,用户看到步骤在推进,心理等待时间会短很多。
4.3 错误处理与重试机制
Agent 流程中最常见的错误是模型调用超时或者返回格式不符合预期。我的处理策略是:
- 节点级重试:每个 LLM 调用包一层重试逻辑,最多重试 2 次
- 降级方案:如果解析节点连续失败,直接跳过结构化解析,用原始文本进入后续流程
- 用户可见的错误:不要吞掉错误,通过 SSE 推一个 error 事件给前端,让用户知道哪一步出了问题
async function withRetry<T>(fn: () => Promise<T>, maxRetries = 2): Promise<T> { for (let i = 0; i <= maxRetries; i++) { try { return await fn(); } catch (err) { if (i === maxRetries) throw err; await new Promise((r) => setTimeout(r, 1000 * (i + 1))); } } throw new Error("unreachable"); }5. 并发场景下的性能与成本控制
5.1 多用户同时请求时的资源竞争问题
简历工具的一个典型使用场景是招聘季,大量用户同时上传简历。这时候如果每个请求都直接调模型,会遇到两个问题:API 速率限制和成本飙升。
我的做法是在 Route Handler 层面加一个简单的队列控制:
const queue = new PQueue({ concurrency: 5 }); export async function POST(req: Request) { return queue.add(async () => { // 实际的 Agent 处理逻辑 }); }p-queue这个库很轻量,能有效控制并发数。具体并发数设多少,取决于你的模型 API 配额。我用的方案是并发 5,实测在免费额度下也能稳定运行。
5.2 Token 消耗的预估与优化
一个完整的简历优化流程,Token 消耗大概是这样:
| 节点 | 输入 Token | 输出 Token | 说明 |
|---|---|---|---|
| 解析 | ~1500 | ~800 | 取决于简历长度 |
| 匹配 | ~2000 | ~600 | 简历+岗位描述 |
| 重写 | ~1000/段 | ~500/段 | 按段落调用 |
| 总计 | ~6000 | ~3000 | 单次完整流程 |
按这个量级,如果用中等价位的模型,单次成本大概在几分钱。但如果用户频繁修改,成本会线性增长。我的优化策略是:
- 解析结果缓存,同一份简历不重复解析
- 重写节点只对用户选中的段落调用,不批量处理
- 匹配节点用较小的模型,重写节点用较强的模型
5.3 缓存策略:哪些结果可以复用
我在 State 之外加了一层 Redis 缓存,key 是简历内容的 hash 值。解析结果和匹配结果都可以缓存,因为这两个节点的输出只取决于输入内容,不依赖用户交互。
const cacheKey = `resume:parse:${hash(rawResume)}`; const cached = await redis.get(cacheKey); if (cached) return JSON.parse(cached); // ... 执行解析 await redis.set(cacheKey, JSON.stringify(result), "EX", 3600);重写节点的结果不建议缓存,因为用户可能会反复调整风格要求,缓存命中率低且容易导致体验不一致。
6. 踩坑记录:那些文档里不会写的问题
6.1 LangGraph.js 状态更新的常见误区
我踩的第一个坑是 State 的 reducer 行为。默认情况下,如果你在节点里返回{ parsedSections: newValue },LangGraph 会用新值覆盖旧值。但如果某个字段定义了累加 reducer,返回数组时会追加而不是替换。
我一开始没注意这个区别,导致 suggestions 数组越跑越长,同一个建议被重复添加了好几次。排查了半天才发现是 reducer 的问题。
注意:定义 State 时一定要想清楚每个字段的更新语义。覆盖还是追加,这个决策会影响后续所有节点的行为。
6.2 模型返回 JSON 格式不稳定的应对
即使加了 Structured Output,模型偶尔还是会返回带 markdown 代码块包裹的 JSON,或者多一段解释文字。我的处理方式是写一个健壮的解析函数:
function safeParseJSON(text: string) { // 去掉 markdown 代码块标记 const cleaned = text.replace(/```json\n?|\n?```/g, "").trim(); try { return JSON.parse(cleaned); } catch { // 尝试提取第一个完整的 JSON 对象 const match = cleaned.match(/\{[\s\S]*\}/); if (match) return JSON.parse(match[0]); throw new Error("无法解析模型输出"); } }这个函数看起来简单,但能挡掉 90% 的格式问题。
6.3 流式响应中断的排查过程
有一次用户反馈说页面经常卡在"正在重写"这一步。我排查了很久,最后发现是 Next.js 的 Route Handler 默认有超时限制,长时间运行的流式响应会被中断。
解决方案是在 Route Handler 里显式设置maxDuration:
export const maxDuration = 60; // 单位秒这个配置在 Vercel 部署时尤其重要,免费版默认超时是 10 秒,对于多节点的 Agent 流程完全不够用。
6.4 前端 SSE 连接断开的自动重连
SSE 连接在网络波动时会断开,浏览器默认会自动重连,但重连后之前的状态就丢了。我的做法是在前端记录已接收的事件序号,重连时带上Last-Event-ID头,服务端从对应位置继续推送。
不过说实话,对于简历工具这种单次会话时长在 30 秒以内的场景,更简单的做法是:检测到连接断开就直接提示用户重试,而不是做复杂的断点续传。我最后选了这个方案,实现成本低,用户体验也能接受。
7. 部署与后续扩展的几点思考
7.1 Vercel 部署的注意事项
Next.js 项目部署到 Vercel 是最顺滑的路径,但有几个点需要注意:
- 环境变量:模型 API Key 必须配置在服务端环境变量里,不要用
NEXT_PUBLIC_前缀 - 函数超时:前面提到的
maxDuration要设置,Pro 版可以到 300 秒 - 边缘运行时:LangGraph.js 依赖 Node.js 的一些 API,不要用 Edge Runtime,用默认的 Node.js Runtime
7.2 从简历工具扩展到求职 Agent 的可能性
这套架构跑通之后,扩展方向其实很清晰。比如加一个"模拟面试"节点,根据简历和岗位生成面试问题;加一个"薪资分析"节点,结合岗位描述给出谈判建议。因为整个流程是图结构,加节点不会影响已有逻辑。
我甚至考虑过把多个工具串成一个大的求职 Agent 图,用户上传简历后,自动走完"优化→模拟面试→投递建议"全流程。技术上完全可行,主要成本在 Prompt 调优和结果质量控制上。
7.3 个人开发者做这类工具的现实建议
最后说点实在的。个人开发者做 AI Agent 工具,最大的挑战不是技术,而是找到真正有人愿意用的场景。简历工具看起来需求明确,但竞争也激烈。我的建议是:
- 先做一个极简版本,只解决一个具体问题,比如"帮我把简历里的项目描述改得更有说服力"
- 找 10 个真实用户试用,收集反馈再决定加什么功能
- 不要一上来就追求大而全,Agent 的复杂度会随着节点数量指数级上升
技术栈方面,Next.js + LangGraph.js 这个组合我个人用下来是顺手的。LangGraph.js 的图抽象让流程清晰,Next.js 的全栈能力让部署简单。如果你已经在用 React 生态,迁移成本很低。
我在实际使用中发现,Agent 类项目的调试时间远超编码时间。建议从一开始就把日志和可观测性做好,每个节点的输入输出都记录下来,后面排查问题会轻松很多。另外,Prompt 一定要版本化管理,每次调整都记录改了什么、效果如何,否则很快就会忘记哪个版本效果最好。