1. 为什么我选择用 Next.js + LangGraph.js 来做简历工具
简历工具这个赛道,表面上看已经被做烂了。市面上一搜,模板站、在线编辑器、PDF 导出工具一抓一大把。但真正动手做过的人都知道,这里面有个绕不开的坎:简历不是填表,它是一个持续迭代、反复打磨、需要针对性调整的内容工程。同一个人投不同岗位,简历的侧重点、措辞、项目排序都应该不一样。传统工具只能给你一个静态编辑器,改来改去还是靠人脑去判断“这段经历该怎么写才匹配 JD”。
我这次想做的,就是把这件事交给一个 AI Agent 去处理。用户上传现有简历,粘贴目标岗位描述,Agent 自动完成解析、匹配、改写建议、评分、导出这一整条链路。技术选型上,前端用 Next.js,Agent 编排用 LangGraph.js,整个项目跑在同一个 TypeScript 技术栈里,前后端不用切换语言,心智负担小很多。
为什么是 LangGraph.js 而不是直接调大模型 API 写几个 if-else?因为简历优化这个流程天然是一个有状态、有分支、可回退的图结构。比如解析完简历后,要根据简历完整度决定是走“补全信息”分支还是直接进入“匹配分析”分支;匹配分析后如果评分低于阈值,要回到改写环节重新生成。这种带条件跳转和循环的逻辑,用 LangGraph 的 StateGraph 来表达非常自然,比手写状态机清晰得多。
这个项目适合谁参考?如果你已经会用 Next.js 写页面,对大模型 API 调用有基本概念,想找一个完整可落地、不是玩具 demo的 AI Agent 项目来练手,那这篇内容就是写给你的。我会把架构设计、核心节点实现、并发处理、踩过的坑全部摊开讲,代码该给的地方给到位,参数该算的地方算清楚。
2. 整体架构设计与技术选型拆解
2.1 三层架构:前端交互层、Agent 编排层、模型服务层
整个项目我拆成了三层,边界划得很清楚,这样后期换模型或者换前端框架都不会伤筋动骨。
前端交互层用 Next.js 的 App Router 搭建。简历上传、JD 输入、Agent 执行过程的可视化流式输出、最终结果展示,全在这一层。之所以选 App Router 而不是 Pages Router,核心原因是 Route Handlers 可以直接跑在 Edge Runtime 或者 Node Runtime 上,Agent 的流式响应通过 Server-Sent Events 推给前端特别顺,不需要额外起一个 WebSocket 服务。
Agent 编排层是 LangGraph.js 的主场。我把整个简历优化流程定义成一个 StateGraph,节点包括简历解析、JD 解析、匹配度分析、内容改写、评分校验、格式化输出。每个节点是一个纯函数,输入输出都是明确定义的 State 对象。这一层不直接碰数据库,也不碰 HTTP 请求,保持纯粹,方便单测。
模型服务层做了个抽象,统一封装成LLMProvider接口。默认走 OpenAI 兼容协议,但你可以换成任何兼容的模型服务。这样做的好处是,Agent 编排层完全不知道底层用的是哪个模型,换模型只改一个配置文件。
三层之间的数据流是这样的:前端发一个 POST 请求带上简历文本和 JD 文本,Route Handler 接收后初始化 State,调用编译好的 LangGraph 实例,图执行过程中每个节点的输出通过 stream 事件推回前端,前端实时渲染进度,最后拿到完整结果。
2.2 为什么用 LangGraph.js 而不是 LangChain 的 Chain
很多人会问,LangChain 的 Chain 也能串起来,为什么要上 LangGraph?我实际对比过两种方案,结论很明确:简历优化流程里有循环和条件分支,Chain 表达起来很别扭。
举个具体例子。简历改写这个环节,我希望 Agent 生成改写建议后,自动用另一个评分节点打分,如果分数低于 80 分,就带着评分反馈回到改写节点重新生成,最多重试 3 次。这个“生成-评分-不达标则回退”的循环,用 LangGraph 就是一个带条件边的环,代码大概长这样:
const workflow = new StateGraph(ResumeState) .addNode("rewrite", rewriteNode) .addNode("score", scoreNode) .addConditionalEdges("score", (state) => { if (state.score >= 80 || state.retryCount >= 3) { return "format"; } return "rewrite"; }) .addEdge("rewrite", "score");如果用 Chain 来实现,你得手动写 while 循环,还得自己管理中间状态,代码可读性和可维护性差一大截。而且 LangGraph 自带 checkpoint 机制,每个节点的状态可以持久化,万一某一步失败了,可以从上一个 checkpoint 恢复,不用从头跑。这个特性在生产环境里非常值钱。
2.3 State 结构设计:Agent 的“记忆”怎么组织
State 是整个 Agent 的核心,设计得好不好直接决定后续开发顺不顺。我最终定下来的 State 结构是这样的:
interface ResumeState { rawResume: string; // 原始简历文本 rawJD: string; // 原始 JD 文本 parsedResume: ParsedResume; // 结构化后的简历 parsedJD: ParsedJD; // 结构化后的 JD matchReport: MatchReport; // 匹配度分析报告 suggestions: Suggestion[]; // 改写建议列表 score: number; // 当前评分 retryCount: number; // 重试次数 finalOutput: string; // 最终输出 errors: string[]; // 错误收集 }这里有个设计决策值得说:我把原始文本和结构化数据都保留在 State 里。原因是,改写节点有时候需要回看原始简历的措辞,如果只保留结构化数据,一些语气、细节就丢了。多存一份原始文本,内存开销可以忽略,但换来的灵活性很值。
另外errors字段是个数组而不是单个字符串,因为一个节点可能产生多个非致命错误,收集起来统一在最后展示,比遇到第一个错误就中断体验好得多。
3. 核心节点实现与实操要点
3.1 简历解析节点:从自由文本到结构化数据
简历解析是整个流程的第一步,也是最容易出问题的一步。用户上传的简历格式五花八门,有 PDF 转出来的乱码,有表格排版的,有中英文混排的。我的策略是先用规则做预处理,再交给模型做结构化抽取。
预处理阶段主要做三件事:去掉多余空行和特殊字符、识别并标记章节标题(如“工作经历”“教育背景”)、把明显的联系方式用正则提取出来。这一步能减轻模型负担,也能提高抽取准确率。
结构化抽取的 prompt 我调了很多版,最终稳定下来的核心指令是这样的:
const parsePrompt = `你是一个简历解析器。请从下面的简历文本中抽取结构化信息。 要求: 1. 工作经历按时间倒序排列 2. 每段经历必须包含公司、职位、起止时间、职责描述 3. 如果某个字段在原文中找不到,填 null,不要编造 4. 职责描述保留原文措辞,不要改写 简历文本: ${rawResume} 请以 JSON 格式输出,schema 如下: { "basicInfo": { "name": string, "email": string, "phone": string }, "workExperience": [{ "company": string, "title": string, "start": string, "end": string, "description": string }], "education": [...], "skills": string[] }`;这里有个关键点:明确要求模型“找不到就填 null,不要编造”。我早期版本没写这句,结果模型经常自作主张补全一些不存在的信息,比如给一个没写时间的经历编个日期,这在简历场景里是致命的。
注意:解析节点一定要做 JSON 解析的容错。模型有时候会在 JSON 外面包一层 markdown 代码块标记,或者末尾多一个逗号。我写了个
safeJsonParse函数,先尝试直接解析,失败就正则提取第一个{到最后一个}之间的内容再解析,再失败就抛错进入错误收集。
3.2 JD 解析与匹配度分析节点
JD 解析相对简单,核心是抽取硬性要求和加分项。我在 prompt 里让模型把 JD 拆成三类:必须满足的技能、优先考虑的技能、职责描述。这样后续匹配分析时,可以给不同类别不同权重。
匹配度分析节点是整个 Agent 里逻辑最重的一个。我的做法是分维度打分再加权汇总,而不是让模型直接给一个总分。维度包括:
| 维度 | 权重 | 说明 |
|---|---|---|
| 技能匹配 | 0.35 | JD 要求的技能在简历中出现的比例 |
| 经验年限 | 0.20 | 工作年限是否满足 JD 要求 |
| 行业相关性 | 0.20 | 过往行业与目标岗位行业的契合度 |
| 项目复杂度 | 0.15 | 项目描述的深度和规模 |
| 教育背景 | 0.10 | 学历和专业匹配度 |
每个维度让模型输出 0-100 的分数和一句理由,然后我用代码做加权计算。这样做的好处是可解释性强,用户能看到自己哪一项弱,而不是只看到一个冷冰冰的总分。而且权重可以做成配置项,不同岗位类型用不同权重,比如技术岗技能权重高,管理岗经验权重高。
3.3 内容改写节点:让建议真正可落地
改写节点是最能体现 Agent 价值的地方。我不满足于只给“建议你突出项目成果”这种空话,而是要求模型直接给出改写后的文本,并且标注改了什么、为什么改。
改写 prompt 的核心结构是:把匹配分析中得分低的维度作为改写重点,把 JD 中的关键词作为必须融入的元素,把原始简历中的经历作为素材。输出格式要求是:
interface Suggestion { original: string; // 原文 rewritten: string; // 改写后 reason: string; // 改写理由 keywords: string[]; // 融入的关键词 }这里踩过一个坑:早期我让模型自由发挥改写,结果它经常把原文改得面目全非,甚至添加了原文没有的经历。后来我在 prompt 里加了硬约束:“改写必须基于原文事实,只能调整措辞、顺序和重点,不得添加原文不存在的信息”。同时加了一个校验步骤,用另一个模型调用检查改写后的内容是否引入了原文没有的实体(公司名、项目名、数字),如果有就标记出来让用户人工确认。
3.4 评分校验与循环控制
评分节点复用匹配度分析的逻辑,但输入变成了改写后的简历。这里的关键是设置合理的阈值和重试上限。
阈值我定在 80 分,这个数字是测出来的。我拿 20 份真实简历跑了测试,发现 80 分以上的改写版本,人工评估“明显优于原文”的比例达到 85%;而 70-80 分区间的,这个比例只有 60% 左右。所以 80 是个性价比比较高的线。
重试上限设 3 次,是因为实测下来,超过 3 次之后模型基本在原地打转,很难再有实质性提升,反而浪费 token。而且每次重试都要把上一轮的评分反馈带上,让模型知道差在哪:
const rewritePrompt = `上一轮改写得分 ${state.score},未达到 80 分。 失分点:${state.matchReport.weakPoints.join("、")} 请针对这些失分点重新改写,其他部分保持不变。`;4. 并发处理与性能优化实战
4.1 AI Agent 怎么扛并发:我的限流与队列方案
“AI Agent 怎么扛并发”是最近被问得最多的问题之一。简历工具的场景下,并发压力主要来自两个方面:一是多个用户同时提交优化请求,二是单个请求内部多个模型调用的并行。
先说用户级别的并发。我的方案是令牌桶限流 + 请求队列。每个用户 ID 对应一个令牌桶,默认容量 3,每秒补充 1 个令牌。请求进来先取令牌,取不到就进队列等待,队列满了直接返回 429。这个逻辑我写成了一个 Next.js 的 middleware,对所有 Agent 相关的 API 路由生效。
class TokenBucket { private tokens: number; private lastRefill: number; constructor(private capacity: number, private refillRate: number) { this.tokens = capacity; this.lastRefill = Date.now(); } tryConsume(): boolean { this.refill(); if (this.tokens >= 1) { this.tokens -= 1; return true; } return false; } private refill() { const now = Date.now(); const elapsed = (now - this.lastRefill) / 1000; this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate); this.lastRefill = now; } }再说请求内部的并行。LangGraph 支持节点并行执行,我把简历解析和 JD 解析这两个互不依赖的节点并行跑,整体耗时能省 30% 左右。实现方式是在图定义时把两个节点都从 START 出发:
.addEdge(START, "parseResume") .addEdge(START, "parseJD") .addEdge("parseResume", "match") .addEdge("parseJD", "match")LangGraph 会自动识别这两个节点可以并行,等两个都完成后才进入 match 节点。这个特性在文档里叫“fan-out/fan-in”,用起来很省心。
4.2 流式输出:让用户看到 Agent 在干活
Agent 执行动辄十几秒,如果前端一直转圈,用户会以为卡死了。我用 Server-Sent Events 把每个节点的执行状态实时推给前端。
Next.js 的 Route Handler 里返回一个 ReadableStream,LangGraph 的streamEvents方法会产出每个节点开始和结束的事件,我把这些事件转成 SSE 格式写进流里:
export async function POST(req: Request) { const { resume, jd } = await req.json(); const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { const events = graph.streamEvents(initialState, { version: "v2" }); for await (const event of events) { const data = JSON.stringify({ node: event.name, status: event.event, timestamp: Date.now() }); controller.enqueue(encoder.encode(`data: ${data}\n\n`)); } controller.close(); } }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive" } }); }前端用 EventSource 接收,每收到一个事件就更新进度条和当前步骤文案。实测下来,用户看到“正在解析简历”“正在分析匹配度”这样的实时反馈,等待焦虑感明显降低,中途放弃率下降了差不多一半。
4.3 缓存策略:省 token 就是省钱
简历优化这个场景有个特点:同一份简历可能被反复优化多次,每次只是 JD 不同。简历解析的结果完全可以缓存起来。
我用的是两级缓存:内存缓存(LRU)加 Redis。key 是简历文本的 SHA-256 哈希,value 是解析后的结构化数据。内存缓存存最近 100 条,命中直接返回;没命中查 Redis;Redis 也没有才调模型。TTL 设 24 小时,因为简历内容一般不会频繁变动。
这个优化效果很显著。测试环境下,缓存命中率大概 40%,意味着 40% 的请求省掉了一次简历解析的模型调用。按每次解析消耗约 2000 token 算,一天 1000 次请求能省 80 万 token,成本降下来不少。
注意:缓存 key 一定要用哈希而不是原始文本,否则 Redis 里存大段文本很占内存。另外哈希算法要选碰撞率低的,SHA-256 足够,别用 MD5。
5. 常见问题与排查技巧实录
5.1 模型输出格式不稳定怎么办
这是做 AI Agent 最常遇到的问题。明明 prompt 里写了要 JSON,模型有时候就是给你返回一段带解释的文字。我的应对策略是三层防御:
第一层,prompt 里用 few-shot 示例,给一两个输入输出的完整例子,比单纯描述格式有效得多。第二层,用safeJsonParse做容错解析,处理代码块标记、多余逗号、单引号这些常见问题。第三层,解析失败时触发一次“修复调用”,把原始输出和错误信息一起发给模型,让它重新输出合法 JSON。
实测下来,三层防御能把格式错误率从最初的 15% 降到 1% 以下。剩下那 1% 基本是模型服务本身的问题,重试一次就好。
5.2 Agent 执行超时怎么处理
LangGraph 的图执行默认没有超时限制,一个节点卡住整个请求就挂了。我给每个节点包了一层超时控制:
async function withTimeout<T>(promise: Promise<T>, ms: number, nodeName: string): Promise<T> { const timeout = new Promise<never>((_, reject) => { setTimeout(() => reject(new Error(`节点 ${nodeName} 执行超时 (${ms}ms)`)), ms); }); return Promise.race([promise, timeout]); }超时时间按节点类型设置:解析类节点 30 秒,改写类节点 60 秒,评分类节点 20 秒。这些数字是根据实际调用耗时分布定的,取 P99 耗时再留 50% 余量。超时后不是直接失败,而是把当前 State 存到 checkpoint,返回一个“部分完成”的结果给用户,用户可以点“继续”从断点恢复。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 解析结果字段缺失 | prompt 不够明确 | 检查 prompt 是否要求了所有字段 | 补充字段说明和 few-shot 示例 |
| 改写内容编造经历 | 缺少事实约束 | 对比原文和改写后的实体 | 加硬约束 prompt + 实体校验节点 |
| 流式输出中断 | SSE 连接被代理切断 | 检查响应头配置 | 加心跳事件,每 15 秒发一个空注释 |
| 并发请求报 429 | 限流触发 | 查看令牌桶配置 | 调整容量和补充速率,或引导用户错峰 |
| 评分波动大 | 模型温度过高 | 检查 temperature 参数 | 评分节点 temperature 设为 0 |
| 缓存不命中 | key 生成不一致 | 检查哈希输入是否包含多余空格 | 哈希前先 trim 和规范化文本 |
5.4 几个我踩过的坑
坑一:LangGraph.js 的版本兼容性。LangGraph.js 迭代很快,0.1.x 和 0.2.x 的 API 有不小差异。我一开始照着旧文档写,addConditionalEdges的参数顺序变了,调了半天才发现。建议锁定版本,升级前先看 changelog。
坑二:Edge Runtime 不支持 Node API。我一开始想把 Agent 跑在 Edge Runtime 上,结果发现 LangGraph 依赖的一些 Node 模块在 Edge 环境跑不了。最后改成 Node Runtime,部署到支持 Node 的环境上。如果你也想用 Edge,先确认依赖兼容性。
坑三:大简历的 token 超限。有用户的简历特别长,加上 JD 和 prompt,直接超过了模型的上下文窗口。我的处理是分段解析:先按章节切分简历,每段单独解析,最后合并。切分逻辑用正则匹配章节标题,切不出来就按固定长度硬切,但硬切可能切断句子,所以优先用语义切分。
坑四:中文简历的编码问题。有些 PDF 转出来的文本里混了全角空格、零宽字符,导致哈希不一致、正则匹配失败。我在预处理阶段加了一步清洗,把所有非标准空白字符统一替换成普通空格,零宽字符直接删掉。
6. 部署与扩展的一些实际考虑
6.1 部署方案选择
这个项目我试过两种部署方式。一种是 Vercel,Next.js 原生支持,部署体验很顺,但 Agent 执行时间长了会碰到函数超时限制,免费版 10 秒,Pro 版 60 秒。简历优化流程跑完通常要 20-40 秒,Pro 版勉强够用,但并发一高就容易超时。
另一种是自建 Node 服务,用 Docker 打包,部署到云服务器。这种方式没有函数超时限制,长任务随便跑,而且可以自己控制并发和资源。我最终选了自建方案,用 PM2 做进程管理,Nginx 做反向代理。Agent 服务单独跑一个进程池,和 Next.js 的页面渲染进程分开,互不影响。
6.2 后续可以扩展的方向
这个 Agent 目前只做了简历优化,但架构是通用的。把 State 结构和节点换一换,就能扩展到其他场景。比如求职信生成、面试问题预测、薪资谈判话术准备,都是同一套编排逻辑,只是 prompt 和评分维度不同。
另一个方向是多轮对话式优化。现在的流程是一次性跑完,用户只能看结果。如果改成对话式,用户可以针对某一条改写建议说“这条再改改,语气太正式了”,Agent 带着这个反馈重新跑改写节点。LangGraph 的 checkpoint 机制天然支持这种交互,把 State 持久化,每次用户输入作为新的事件触发图继续执行。
我个人在实际操作中的体会是,做 AI Agent 项目,最难的不是调模型,而是设计好状态流转和错误处理。模型能力现在都很强,prompt 写清楚基本都能干对活。真正决定项目能不能落地的是:格式不稳定怎么办、超时怎么办、并发怎么办、用户等不及怎么办。这些问题没有标准答案,只能在实际跑的过程中一点点磨。我上面分享的这些方案,都是被真实问题逼出来的,你拿去用的时候,记得根据自己的场景调整参数,别照搬。