1. 为什么我要用 Next.js + LangGraph.js 做简历工具
先说说这个项目的来龙去脉。我在日常工作中经常帮朋友看简历,也帮一些团队做过初筛,发现一个很普遍的问题:大部分人写简历不是能力不行,而是不知道怎么把自己的经历“翻译”成招聘方想看的样子。市面上的简历工具我几乎试了个遍,要么是纯模板填充,要么是套个 AI 接口做润色,生成的内容千篇一律,稍微专业一点的岗位就露怯。
所以我决定自己搭一个真正能“理解”简历的 AI Agent。核心思路是:用Next.js做全栈框架,前端负责交互和实时反馈,后端 API Routes 承载 Agent 的调度逻辑;用LangGraph.js做 Agent 的状态机编排,把“解析简历 → 分析岗位匹配度 → 生成优化建议 → 重写内容 → 自检”这一整套流程拆成可控的节点,而不是丢给大模型一句话就完事。
这个项目适合谁看?如果你已经会用 Next.js 写页面,对 LangChain 或 LangGraph 有初步了解,想做一个真正能跑起来的 AI Agent 应用,那这篇内容就是为你准备的。如果你只是好奇 AI Agent 怎么落地,也可以看看整体架构和踩坑记录,很多问题跟具体技术栈无关。
我先把最终效果说清楚:用户上传一份 PDF 或 Word 简历,粘贴目标岗位的 JD,系统会在 30 秒内输出一份匹配度评分、逐条优化建议、重写后的简历文本,并且给出“为什么这么改”的解释。整个过程不是一次性的 API 调用,而是多个 Agent 节点协作完成,每个节点有明确的输入输出和失败重试机制。
2. 整体架构设计与技术选型拆解
2.1 为什么是 Next.js 而不是纯前端 + 独立后端
很多人做 AI 应用的第一反应是前后端分离,前端用 Vite,后端用 Express 或 FastAPI。我一开始也这么想过,但实际做下来发现 Next.js 的全栈模式在这个场景下优势非常明显。
第一,API Routes 和前端共享 TypeScript 类型。简历解析出来的结构、Agent 每个节点的输入输出、评分维度,这些类型定义在前端和后端之间来回传递,如果分开两个项目,光是同步类型就要花不少精力。Next.js 的app/api目录下直接写服务端逻辑,类型文件放在lib/types.ts,两边 import 同一份定义,改一处就够。
第二,流式输出天然适配。Agent 执行过程中需要把中间状态推给前端,比如“正在解析简历”“正在分析岗位匹配度”“正在生成建议”。Next.js 的 Route Handler 支持 ReadableStream,配合前端的useChat或自定义 hook,可以做到边执行边展示,用户体验比等一个完整响应好太多。
第三,部署简单。Vercel 对 Next.js 的支持不用多说,环境变量、边缘函数、日志监控都是一站式。对于个人项目或小团队来说,省掉运维成本就是省掉生命。
当然也有代价。Next.js 的 API Routes 有执行时间限制,免费版是 10 秒,Pro 版是 60 秒。Agent 多节点串行执行很容易超时,这个后面会讲怎么解决。
2.2 LangGraph.js 在简历场景下的核心价值
LangGraph.js 是 LangChain 团队出的状态机编排库,专门用来做有循环、有分支、有状态的多步骤 Agent。为什么简历工具需要它,而不是简单的 Chain?
因为简历优化本质上是一个迭代过程。用户上传的简历可能信息不全,Agent 需要判断“这份简历是否包含足够的信息来生成建议”,如果不够,要回到上一步追问用户补充;生成建议后,还需要一个“自检节点”来判断建议是否合理、是否与岗位 JD 匹配,不匹配就重新生成。这种带条件分支和循环的流程,用 Chain 写会非常别扭,用 LangGraph 的 StateGraph 就很自然。
我定义的 State 大概长这样:
// lib/agent/state.ts import { Annotation } from "@langchain/langgraph"; export const ResumeAgentState = Annotation.Root({ rawResume: Annotation<string>(), parsedResume: Annotation<ParsedResume>(), jobDescription: Annotation<string>(), matchScore: Annotation<number>(), suggestions: Annotation<Suggestion[]>(), rewrittenResume: Annotation<string>(), retryCount: Annotation<number>(), errors: Annotation<string[]>(), });每个节点接收当前 State,返回要更新的字段。LangGraph 会自动合并,不需要手动管理上下文传递。
2.3 节点划分与职责边界
我把整个 Agent 拆成六个节点,每个节点只做一件事:
| 节点名称 | 职责 | 输入 | 输出 |
|---|---|---|---|
| parseNode | 解析简历文本为结构化数据 | rawResume | parsedResume |
| analyzeNode | 分析岗位匹配度并打分 | parsedResume, jobDescription | matchScore, 分析理由 |
| suggestNode | 生成逐条优化建议 | parsedResume, jobDescription, matchScore | suggestions |
| rewriteNode | 根据建议重写简历 | parsedResume, suggestions | rewrittenResume |
| reviewNode | 自检重写结果是否达标 | rewrittenResume, jobDescription | 通过/不通过 |
| fallbackNode | 处理解析失败或信息不足 | errors | 追问用户 |
节点之间的边用条件函数控制。比如 reviewNode 之后,如果自检不通过且 retryCount 小于 3,就回到 rewriteNode;如果通过,就结束。
注意:节点划分不要太细,否则 LangGraph 的调度开销和调试复杂度会上升。我一开始拆了十个节点,后来合并成六个,执行效率明显提升。
3. 核心细节解析与实操要点
3.1 简历解析:别指望大模型一次搞定
简历解析是整个流程的第一步,也是最容易出问题的一步。我试过直接把 PDF 文本丢给 GPT-4,让它输出 JSON,结果十份简历里有三份格式错乱,尤其是那种双栏排版或者带表格的简历。
后来我改成两步走:先用pdf-parse或mammoth把文件转成纯文本,再用大模型做结构化提取。提取的时候给一个严格的 JSON Schema,并且要求模型对不确定的字段返回 null,而不是瞎编。
// lib/agent/nodes/parse.ts import { ChatOpenAI } from "@langchain/openai"; import { z } from "zod"; const ParsedResumeSchema = z.object({ name: z.string().nullable(), email: z.string().nullable(), phone: z.string().nullable(), education: z.array(z.object({ school: z.string(), degree: z.string(), major: z.string(), startDate: z.string().nullable(), endDate: z.string().nullable(), })), experience: z.array(z.object({ company: z.string(), title: z.string(), startDate: z.string().nullable(), endDate: z.string().nullable(), highlights: z.array(z.string()), })), skills: z.array(z.string()), projects: z.array(z.object({ name: z.string(), role: z.string().nullable(), description: z.string(), highlights: z.array(z.string()), })), }); export async function parseNode(state: typeof ResumeAgentState.State) { const model = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 }); const structuredModel = model.withStructuredOutput(ParsedResumeSchema); try { const parsed = await structuredModel.invoke([ { role: "system", content: "你是一个简历解析助手,请从以下文本中提取结构化信息。不确定的字段返回 null。" }, { role: "user", content: state.rawResume }, ]); return { parsedResume: parsed, errors: [] }; } catch (e) { return { errors: [`解析失败: ${e.message}`], retryCount: state.retryCount + 1 }; } }这里有个关键点:temperature 设为 0。简历解析是确定性任务,不需要创造性,温度越低输出越稳定。另外用withStructuredOutput比手动写 prompt 要求 JSON 格式可靠得多,LangChain 会自动处理 schema 校验和重试。
3.2 匹配度分析:让评分有据可依
匹配度评分不能是一个黑盒数字。用户看到“匹配度 72 分”第一反应是“凭什么”。所以我在 analyzeNode 里要求模型输出评分的同时,给出每个维度的得分和理由。
评分维度我定了五个:技能匹配、经验匹配、教育背景、项目相关性、关键词覆盖。每个维度 0-20 分,总分 100。模型需要逐项说明扣分原因。
const MatchAnalysisSchema = z.object({ overallScore: z.number().min(0).max(100), dimensions: z.array(z.object({ name: z.string(), score: z.number().min(0).max(20), reason: z.string(), })), missingKeywords: z.array(z.string()), strengths: z.array(z.string()), weaknesses: z.array(z.string()), });实测下来,这种结构化输出比单纯让模型“打个分”有用得多。用户能清楚看到自己哪里弱,而不是只看到一个数字干着急。
3.3 建议生成与重写:分开还是合并
我一开始把“生成建议”和“重写简历”放在一个节点里,让模型直接输出优化后的简历。结果发现两个问题:一是模型容易在重写时丢掉原始简历里的关键信息;二是用户看不到修改逻辑,不知道哪里变了。
后来拆成两个节点。suggestNode 只负责生成建议列表,每条建议包含:问题描述、修改方向、示例改法。rewriteNode 拿到建议后,逐条应用到简历上,并且保留修改痕迹。
const SuggestionSchema = z.object({ id: z.string(), section: z.enum(["experience", "project", "skill", "education", "summary"]), original: z.string(), issue: z.string(), suggestion: z.string(), example: z.string(), });这样拆的好处是,用户可以先看建议,选择性地接受或拒绝,再让 Agent 重写。虽然后端实现上多了一次模型调用,但用户体验和可控性提升明显。
3.4 自检节点:Agent 的“良心”
reviewNode 是我认为整个项目里最有价值的节点。它的作用是:拿到重写后的简历,对照岗位 JD,判断是否还有明显不匹配的地方,以及重写是否引入了事实错误。
这个节点用另一个模型实例,prompt 里明确要求“以招聘方视角审视这份简历”。如果发现重写后的内容与原始简历事实不符(比如把“参与”改成了“主导”),直接判定不通过,触发重写。
const ReviewSchema = z.object({ passed: z.boolean(), issues: z.array(z.string()), factCheckPassed: z.boolean(), });实操心得:自检节点的模型最好和重写节点用不同的模型,比如重写用 GPT-4o,自检用 Claude 或 Gemini。不同模型的偏见不同,交叉检查能发现更多问题。我试过同一个模型自检,通过率虚高,换模型后拦截了不少事实性错误。
4. 实操过程与核心环节实现
4.1 项目初始化与依赖安装
先创建 Next.js 项目,用 App Router:
npx create-next-app@latest resume-agent --typescript --tailwind --app --src-dir cd resume-agent安装核心依赖:
npm install @langchain/langgraph @langchain/openai @langchain/core zod npm install pdf-parse mammoth npm install -D @types/pdf-parse这里注意pdf-parse在 Next.js 的 Edge Runtime 下不能用,需要在 Route Handler 里声明export const runtime = 'nodejs'。我踩过这个坑,部署到 Vercel 后一直报错,查了半天才发现是运行时环境问题。
4.2 构建 LangGraph 状态图
在lib/agent/graph.ts里定义图结构:
import { StateGraph, END } from "@langchain/langgraph"; import { ResumeAgentState } from "./state"; import { parseNode } from "./nodes/parse"; import { analyzeNode } from "./nodes/analyze"; import { suggestNode } from "./nodes/suggest"; import { rewriteNode } from "./nodes/rewrite"; import { reviewNode } from "./nodes/review"; const workflow = new StateGraph(ResumeAgentState) .addNode("parse", parseNode) .addNode("analyze", analyzeNode) .addNode("suggest", suggestNode) .addNode("rewrite", rewriteNode) .addNode("review", reviewNode) .addEdge("__start__", "parse") .addConditionalEdges("parse", (state) => { if (state.errors.length > 0 && state.retryCount < 3) return "parse"; if (state.errors.length > 0) return END; return "analyze"; }) .addEdge("analyze", "suggest") .addEdge("suggest", "rewrite") .addEdge("rewrite", "review") .addConditionalEdges("review", (state) => { if (state.retryCount >= 3) return END; return state.reviewPassed ? END : "rewrite"; }); export const resumeAgent = workflow.compile();这个图里有两个条件边:parse 之后如果解析失败且重试次数小于 3,回到 parse 重试;review 之后如果不通过且重试次数小于 3,回到 rewrite 重写。其他都是线性推进。
4.3 API Route 与流式输出
在app/api/optimize/route.ts里实现流式接口:
export const runtime = 'nodejs'; export const maxDuration = 60; export async function POST(req: Request) { const { resumeText, jobDescription } = await req.json(); const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { const send = (event: string, data: any) => { controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)); }; try { const events = resumeAgent.streamEvents( { rawResume: resumeText, jobDescription, retryCount: 0, errors: [] }, { version: "v2" } ); for await (const event of events) { if (event.event === "on_chain_start") { send("node_start", { node: event.name }); } if (event.event === "on_chain_end" && event.name === "review") { send("result", event.data.output); } } controller.close(); } catch (e) { send("error", { message: e.message }); controller.close(); } }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", }, }); }前端用EventSource或fetch的 ReadableStream 读取,每收到一个node_start事件就更新 UI 上的进度条,收到result就展示最终结果。
4.4 前端交互与状态管理
前端我用的 React Server Components + Client Components 混合模式。上传和输入部分用 Client Component,结果展示用 Server Component 做初始渲染,流式更新部分用 Client Component 的useState管理。
关键代码在app/optimize/page.tsx:
"use client"; import { useState } from "react"; export default function OptimizePage() { const [stage, setStage] = useState<string>("idle"); const [result, setResult] = useState<any>(null); const handleSubmit = async (resumeText: string, jd: string) => { setStage("parsing"); const res = await fetch("/api/optimize", { method: "POST", body: JSON.stringify({ resumeText, jobDescription: jd }), }); const reader = res.body?.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (reader) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n\n"); buffer = lines.pop() || ""; for (const line of lines) { if (line.startsWith("event: node_start")) { const data = JSON.parse(line.split("data: ")[1]); setStage(data.node); } if (line.startsWith("event: result")) { const data = JSON.parse(line.split("data: ")[1]); setResult(data); setStage("done"); } } } }; // 渲染逻辑省略 }注意:SSE 的 buffer 处理一定要用
split("\n\n")而不是split("\n"),因为 SSE 事件之间用空行分隔。我一开始用单换行分割,导致 JSON 解析经常失败。
5. 常见问题与排查技巧实录
5.1 超时问题:Agent 执行太久怎么办
这是 Next.js + LangGraph 组合最常见的坑。Vercel 免费版 API Route 最长 10 秒,Pro 版 60 秒。我的 Agent 完整跑一遍平均 25-40 秒,免费版必超时。
解决方案有三个,我最终选了组合方案:
第一,把解析和后续步骤拆成两个 API。上传文件后先调/api/parse,解析结果存到数据库或 KV,返回一个 taskId。然后前端用 taskId 调/api/optimize,这样每个接口的执行时间都控制在 20 秒以内。
第二,用maxDuration声明。在 Route Handler 里加export const maxDuration = 60,Vercel Pro 会识别并放宽限制。
第三,流式输出 + 心跳。即使总时长超过限制,只要持续有数据推送给客户端,连接就不会断。我在每个节点开始时都发一个node_start事件,相当于心跳。
5.2 模型输出不稳定:JSON 解析失败
即使用了withStructuredOutput,偶尔还是会遇到模型输出不符合 schema 的情况。我的处理方式是加一层 Zod 校验和重试:
async function invokeWithRetry(model, schema, messages, maxRetries = 3) { for (let i = 0; i < maxRetries; i++) { try { const result = await model.withStructuredOutput(schema).invoke(messages); return result; } catch (e) { if (i === maxRetries - 1) throw e; await new Promise(r => setTimeout(r, 1000 * (i + 1))); } } }指数退避重试能解决大部分偶发问题。如果三次都失败,说明 prompt 本身有问题,需要回去检查。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 部署后报错 pdf-parse | Edge Runtime 不支持 | 检查 Route 是否声明 nodejs | 加export const runtime = 'nodejs' |
| SSE 数据解析失败 | buffer 分割方式错误 | 打印原始 buffer | 用split("\n\n")分割事件 |
| Agent 执行超时 | 节点串行时间过长 | 加日志看各节点耗时 | 拆分 API + 流式心跳 |
| 评分虚高 | 模型过于宽松 | 对比人工评分 | 换自检模型 + 加严 prompt |
| 重写丢信息 | 单节点任务过重 | 对比原始和重写文本 | 拆成 suggest + rewrite |
| 类型报错 | State 定义不完整 | 检查 Annotation | 补全所有字段默认值 |
5.4 独家避坑技巧
技巧一:给每个节点加执行日志。在节点函数开头和结尾打时间戳,输出到控制台。部署后看 Vercel 日志,一眼就能定位哪个节点慢。
技巧二:用 LangSmith 做追踪。LangGraph 原生支持 LangSmith,配好环境变量后每个节点的输入输出、耗时、token 消耗都能看到。调试 Agent 的时候这个工具能省一半时间。
技巧三:简历文本先做预处理。PDF 转文本后会有大量换行和空格,先做一次清洗(合并连续换行、去除页眉页脚),再丢给模型,解析成功率能提升不少。
技巧四:给用户一个“重新生成”按钮。Agent 输出有随机性,同一份简历跑两次结果可能不同。让用户能一键重跑,比反复调 prompt 更实用。
6. 后续扩展方向与个人体会
这个项目上线后我自己用了大概两个月,帮十几个朋友改过简历,反馈最好的是“匹配度分析”和“逐条建议”这两个功能。有个朋友拿着优化后的简历去面字节,HR 反馈说“简历写得很清楚,一眼能看到重点”,他专门回来请我吃了顿饭。
后续我打算加两个方向:一是多轮对话式优化,用户可以对某条建议追问“为什么这么改”,Agent 解释后再决定是否采纳;二是岗位画像库,把常见岗位的 JD 特征提前提取好,用户选岗位而不是粘贴 JD,降低使用门槛。
技术层面,LangGraph.js 的interrupt功能我还没用上,它可以让 Agent 在执行中途暂停等待用户输入,非常适合做交互式优化。另外 Next.js 的 Server Actions 也可以替代部分 API Routes,减少前后端胶水代码。
最后分享一个小心得:做 AI Agent 项目,别追求一次到位。我第一版只做了“解析 + 重写”两个节点,跑通了再逐步加分析和自检。每加一个节点就测一轮,确保单节点稳定后再串联。一上来就设计完美架构,大概率会卡在某个细节上出不来。先把最小闭环跑通,再迭代优化,这个节奏对个人项目来说最舒服。