说句实在话,在做这个简历工具AI Agent之前,我一直觉得"AI改简历"这事挺虚的——你把简历丢给ChatGPT,它给你一段建议,然后呢?没有然后了。稍微用多几次就会发现,真正常见的简历修改场景其实是个多轮迭代过程:先让AI把简历里没写清楚的地方找出来,再对着目标岗位的JD做差异分析,然后才是逐段改写,改完还得反问用户"这段经历你是想突出管理能力还是执行能力"。这个流程用一次性Prompt根本做不好,必须有一个有状态、会编排的Agent在背后兜着。
所以我把技术栈定在了Next.js + LangGraph.js。Next.js负责Web层和API层,LangGraph.js负责Agent的有状态编排。这篇博文就是一次完整落地复盘:架构选型的理由、StateGraph工作流怎么设计、API Route怎么把流式输出接到前端,以及并发、超时、token成本这些一上生产就躲不开的问题。适合已经写过几个AI应用、正琢磨着把"单轮问答"升级成"多轮Agent"的开发者,也适合那些想在简历工具这类垂直场景里做产品化尝试的团队参考。
1. 从"聊天框"到"生产力工具":简历Agent到底解决什么问题
1.1 通用对话式简历工具的三大硬伤
拿通用ChatGPT改简历,最常见的问题有三个。
第一个是上下文一次性。你把简历贴进去,它给了建议,下一轮对话基本又把前面的信息丢了,除非你反复复制粘贴。简历修改偏偏又是一个强依赖上下文的场景——改完工作经历段之后,技能标签怎么写、项目时间线怎么对齐,都要基于前一版的内容。
第二个是缺少流程感。改简历的正确顺序应该是解析、诊断、改写、核验,但通用对话模型根本不懂这个顺序。你问一句它答一句,最后改出来的东西东一榔头西一棒子,可能诊断结果看着挺对,实际改写的时候却完全没按诊断来。
第三个是输出格式不可控。你让它输出"修改后的简历",它可能给你一段总结,而不是可以直接粘贴的段落。对工具类产品来说,不可控的输出格式就意味着没法做后续的自动化处理。
这些都是"无状态聊天"天然带来的问题。所以我的判断很明确:简历工具必须Agent化。Agent和普通聊天的区别,就是它把"对话"变成了"任务执行"——有流程、有状态、有分支、有结果校验。
1.2 为什么是Next.js + LangGraph.js而不是别的组合
技术选型的时候其实还有几个方案:FastAPI加LangGraph Python、纯前端直接调大模型API、或者用Spring AI那套Java方案。逐个分析下来,我最后还是选了Next.js + LangGraph.js,核心原因有三个。
第一是和前端生态打通太顺。Next.js本身就是全栈框架,API Route和前端页面在同一个项目里,简历上传、结果展示、流式输出都很好处理,不用额外维护两个服务。对一个小型工具类产品来说,单仓库单部署链路能省掉大量运维成本。
第二是TypeScript类型可以复用。Agent的状态定义、工具函数的入参出参、API接口的响应结构,前后端可以共享一套类型。排查问题的时候,一个字段名不对,编译期就报错了,不用等到运行时才在数据里翻来翻去。
第三是LangGraph.js本身的StateGraph和checkpoint机制,天然适合多轮任务流程。它能显式定义节点和边,能保存会话状态,还能在节点之间做条件跳转。这些正是简历工具需要的能力。至于LangGraph Python,功能更全,但和前端联调要多一层RPC服务,对这个小项目来说有点重。
1.3 功能边界设定:不做什么比做什么更重要
做Agent最容易犯的错是贪多。我见过很多人一上来就想着"做一个全能的简历助手",结果Prompt写了一千行,模型经常出错,调试起来痛不欲生。我自己也踩过这个坑,所以这次从一开始就把功能边界划得很清楚:只做四个能力——简历解析、岗位匹配诊断、定向改写、多轮澄清。不做简历排版美化、不做智能推荐投递、不做自动生成虚假经历。
为什么这样划?因为这四个能力有一个共性:它们都基于文本理解和生成,是LLM的强项。而排版美化需要精确的CSS渲染控制,推荐投递需要额外的数据源和匹配算法,这些都不是大模型擅长的,硬塞进来只会稀释Agent的稳定性和产品定位。
边界定清楚之后,Prompt设计、节点划分、测试用例都好写很多。这个教训我想放在最前面:Agent不是越全能越好,而是越可控越好。你划清边界的那一刻,产品形态其实已经清晰了一半。
2. 环境搭建与版本选型:先把地基打稳
2.1 Next.js项目初始化的版本选择
我用的是Next.js 14的App Router。为什么不用Pages Router?因为App Router的Route Handlers对流式响应支持更好,而简历Agent的诊断和改写结果都是流式返回的,这个能力很关键。初始化命令很简单:
npx create-next-app@latest resume-agent --typescript --tailwind --app这里有个容易踩的坑:Next.js 15的某些中间版本和@langchain/langgraph存在兼容性问题,主要是动态API在build时被强制静态化导致的。如果你遇到"Dynamic server usage"的报错,要么在route handler里显式声明export const dynamic = 'force-dynamic',要么直接退回14.x。我实测下来14.x最稳,这个项目选型不是越新越好,踩过一遍之后的体会是:生态兼容性才是全栈AI应用的生死线。
依赖安装这块,直接装这三个包就行:
npm install @langchain/langgraph @langchain/openai langchainlangchain这个包主要用来拿BaseMessage这些基础类型和一些工具函数,主导航还是@langchain/langgraph。
2.2 LangGraph.js的版本对齐教训
这里必须提一个版本对齐的坑:LangChain生态的版本更新非常频繁,@langchain/langgraph和langchain如果大版本不一致,运行时会出现各种奇怪的错误。我遇到过最离谱的一次是this.llm is not a function,查了半天才发现是langchain新版本改了内部接口,而@langchain/langgraph还停留在旧版。
我现在的做法是全部锁大版本,package.json里用^0.2.0这样的范围,并且把lock文件提交到仓库,绝对不用latest。每次升级的时候,先看两个包的changelog,确认Breaking Change列表里没有我用到的方法再动手。
2.3 环境变量与模型Provider封装
模型我用的GPT-4o-mini做解析节点,GPT-4o做诊断和改写节点。为什么要分开?因为解析是提取信息,不需要太多推理,便宜快的就行;诊断和改写才需要真正的推理能力,要用大模型保证质量。
环境变量这样配置:
OPENAI_API_KEY=sk-... NEXT_PUBLIC_APP_URL=http://localhost:3000然后写一个统一的provider封装。这里多说一句:把模型调用封装成函数,而不是在节点里直接new ChatOpenAI,测试的时候mock会非常方便。我在src/lib/provider.ts里统一导出模型实例,切换模型只需要改一个地方,不用动节点代码。
// src/lib/provider.ts import { ChatOpenAI } from "@langchain/openai"; export const fastModel = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0, maxTokens: 2048, }); export const strongModel = new ChatOpenAI({ model: "gpt-4o", temperature: 0.3, maxTokens: 4096, });3. 核心工作流设计:解析、诊断、改写三个节点的编排逻辑
3.1 StateGraph状态图定义:状态就是Agent的记忆
LangGraph.js的核心是状态图。一切流程都在StateGraph上展开,所以状态怎么定义,直接决定了Agent能记住什么、不能记住什么。我的状态结构是这样:
// src/agent/state.ts import { Annotation } from "@langchain/langgraph"; import { BaseMessage } from "langchain"; export const AgentState = Annotation.Root({ // 原始输入 resumeText: Annotation<string>(), jobDescription: Annotation<string>(), // 阶段产物 parsedResume: Annotation<Record<string, any>>(), diagnosis: Annotation<Record<string, any>>(), rewrittenSections: Annotation<Record<string, string>>(), // 对话消息累积 messages: Annotation<BaseMessage[]>({ reducer: (a, b) => a.concat(b), }), // 迭代计数,防止死循环 iterationCount: Annotation<number>({ reducer: (a, b) => a + b, }), // 是否需要用户澄清 needsClarification: Annotation<boolean>(), });这里的要点是reducer。LangGraph状态里的每个字段都可以定义自己的合并逻辑。我的messages用concat来累积消息,这样每一轮节点产生的消息都会追加到历史里,多轮对话不会丢上下文。iterationCount用加法,主要用来做循环保护——如果Agent在澄清问题上转圈超过3轮,就强制进入改写节点,避免用户体验灾难。
3.2 节点一:简历解析与信息抽取
第一个节点负责把原始简历文本转成结构化对象。这里的关键是:这个节点只做提取,不做评价。输出一个干净的JSON,包含基本信息、工作经历列表、技能标签、项目经历这几块。
// src/agent/nodes/parseResume.ts export async function parseResume(state: typeof AgentState.State) { const prompt = ChatPromptTemplate.fromMessages([ ["system", RESUME_PARSE_SYSTEM_PROMPT], ["human", "请解析以下简历文本:\n\n{resumeText}"], ]); const model = fastModel.withStructuredOutput(ResumeSchema); const chain = prompt.pipe(model); const parsed = await chain.invoke({ resumeText: state.resumeText }); return { parsedResume: parsed }; }用withStructuredOutput可以保证输出一定是JSON结构,后续节点就不用再做一堆防御性解析。ResumeSchema用zod定义,比如工作经历必须有company、position、startDate、endDate、responsibilities、achievements这些字段。有一个细节:解析时要保留原文段落索引。因为后面改写节点需要知道"改的是第几段",如果丢了索引,改写结果和原文没法对齐。
3.3 节点二:岗位匹配度诊断
诊断节点是Agent最像"人"的地方。把目标JD和解析后的简历放一起,让模型从几个维度打分:经历匹配度、技能匹配度、成果量化程度、表达清晰度。输出是一个结构化的诊断报告,包含短板列表和优先级排序。
这里我要强调一个经验:不要把诊断逻辑写成Prompt里的一段话,而是拆成"维度定义 + 评分标准 + 输出格式"三个部分。维度定义告诉模型看什么,评分标准告诉模型怎么量化,输出格式告诉模型怎么组织结果。三段式拆分之后,模型的输出质量提升非常明显。
诊断报告的数据结构大概是:
{ "overallScore": 68, "dimensions": { "experienceMatch": { "score": 75, "comment": "前后端经历齐全,但缺少高并发项目" }, "skillMatch": { "score": 60, "comment": "JD要求的K8s经验未体现" }, "quantification": { "score": 55, "comment": "多数成果没有量化数字" }, "clarity": { "score": 80, "comment": "表达总体清晰,职责描述偏啰嗦" } }, "shortages": ["缺少K8s关键词", "项目成果未量化", "领导力体现不足"], "priorities": ["项目成果量化", "补充JD关键词", "简化职责描述"] }这个诊断结果要作为后续改写节点的约束条件,相当于让改写节点照着"处方"抓药,而不是自由发挥。
3.4 节点三:定向改写与结构化输出
改写节点收到诊断结果,针对短板逐段生成改写后的文本。这里我用了一个技巧:把改写范围严格限定为工作经历描述和项目描述两段,不碰技能标签和基本信息,避免模型自作主张改坏。
改写的核心Prompt长这样:
你是一位资深技术面试官和简历写作教练。请根据诊断报告({diagnosis}), 针对目标岗位({jobDescription})的要求,重写下列工作经历段落({originalSection})。 要求: 1. 保留全部真实事实,不得虚构 2. 给每个职责项补充可量化的成果,如果没有数字,用{建议数字} 3. 在描述中自然融入JD要求的关键词,不要生硬堆砌 4. 每段不超过3行,使用STAR法则注意"保留全部真实事实,不得虚构"这行,这是产品合规底线。简历工具如果生成虚假经历,是会出大事的。
3.5 条件分支与多轮澄清循环:真正的"Agent感"
真正让Agent有"agent感"的是条件分支。我加了一个判断节点:如果诊断发现简历中缺少关键信息——比如项目成果没有量化数据、时间线有断档、目标岗位方向不明确——就让Agent生成澄清问题,等用户回答后,把新的信息注入状态,再回到诊断或改写节点。
LangGraph里用addConditionalEdges实现:
// src/agent/graph.ts graph.addConditionalEdges("assessClarification", (state) => { if (state.needsClarification && state.iterationCount < 3) { return "askUser"; } return "rewrite"; });这个循环设计有一个大好处:用户不用一次把所有信息都给齐。Agent会根据任务进展主动要信息,就像一个真人助手在追问"你这段经历当时服务了多少用户?日活量级是多少?"。实测下来,多轮澄清带来的简历改写质量提升,比单纯调Prompt参数明显得多。
4. 前后端打通:API Route接入LangGraph.js的完整链路
4.1 Route Handler的设计思路
Next.js的Route Handlers天然适合做API层。我的入口长这样:
// app/api/agent/route.ts import { NextRequest } from "next/server"; import { buildResumeAgent } from "@/agent/graph"; export async function POST(req: NextRequest) { const { resumeText, jobDescription, threadId } = await req.json(); const graph = await buildResumeAgent(); const result = await graph.invoke( { resumeText, jobDescription }, { threadId } ); return Response.json(result); }这里threadId是关键。LangGraph的checkpoint机制可以按threadId保存会话状态,这样同一份简历的多次追问不会乱。用户第一次请求传thread-abc,第二轮追问还传thread-abc,Agent就能记住上一轮的诊断结果。如果每次都传新的threadId,那对话历史就断了,Agent会变成"金鱼记忆"。
4.2 流式输出:从一次性JSON到打字机效果
上面那个版本是最基础的invoke,但简历Agent的诊断和改写过程动辄十几秒,让用户干等一个JSON回来,体验太差了。所以我把接口改成了流式。LangGraph.js的stream方法支持流式返回节点事件和token,我直接把它接到ReadableStream上:
// app/api/agent/stream/route.ts import { NextRequest } from "next/server"; import { buildResumeAgent } from "@/agent/graph"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function POST(req: NextRequest) { const encoder = new TextEncoder(); const { resumeText, jobDescription, threadId } = await req.json(); const graph = await buildResumeAgent(); const stream = await graph.stream( { resumeText, jobDescription }, { threadId, streamMode: "messages" } ); return new Response( new ReadableStream({ async start(controller) { try { for await (const chunk of stream) { if (chunk.event === "on_chat_model_stream") { // 只透传模型输出的文本增量 const token = chunk.data.chunk.content; if (token) { controller.enqueue( encoder.encode(JSON.stringify({ type: "token", content: token }) + "\n") ); } } } controller.enqueue(encoder.encode(JSON.stringify({ type: "done" }) + "\n")); } catch (error) { controller.enqueue( encoder.encode(JSON.stringify({ type: "error", message: String(error) }) + "\n") ); } finally { controller.close(); } }, }), { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" } } ); }这里的streamMode: "messages"是关键。它返回的是底层模型的消息增量,前端能直接实现打字机效果。如果不用这个模式,返回的是节点粒度的事件,前端只能一格一格地跳,体验差很多。
4.3 前端消息协议设计
前端那边我维护了一个很简单的协议:每一行JSON要么是{type:"token", content:"..."},要么是{type:"node", name:"diagnose"},要么是{type:"done"}或{type:"error"}。虽然上面代码只透传了token事件,但实际项目中我建议在节点开始时也要发一个node事件,这样前端可以动态展示"正在分析岗位匹配度..."这类状态条,比干等好很多。
核心代码就是解析事件流:
// app/hooks/useAgentStream.ts async function runAgent(input, onToken, onStatus) { const response = await fetch("/api/agent/stream", { method: "POST", body: JSON.stringify(input), }); const reader = response.body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; const lines = decoder.decode(value).split("\n").filter(Boolean); for (const line of lines) { const event = JSON.parse(line); if (event.type === "token") onToken(event.content); if (event.type === "node") onStatus(event.name); if (event.type === "done") onStatus("complete"); } } }小技巧:用\\n分隔事件流里的JSON消息,比用\\n\\n(SSE标准)更容易解析。数据量不大,性能差距可以忽略,但解析逻辑简单很多。
5. 投产前的实测:并发、超时、token成本这些现实问题
5.1 并发压测结果:20并发就报警了
我拿k6做了一轮压测,单实例部署,Vercel Pro。结果很扎心:20并发的时候,响应耗时中位数从3秒涨到了14秒,P95直接破30秒。原因不难理解——LangGraph.js的图执行是一个异步任务链,每个节点都要调一次LLM API,单个请求的LLM调用次数是3到5次。20个并发就意味着1分钟内要处理60到100次模型调用,全都卡在OpenAI API的速率限制和网络延迟上。
应对策略我试了三个,实测有效:
一是把互不依赖的节点并行化。我的解析节点和诊断节点其实不依赖对方,用Promise.all并行跑,单请求耗时能压掉30%。二是模型请求做连接复用,OpenAI的SDK默认就支持,不用额外配置。三是部署到Node.js服务器而不是Serverless,因为Serverless实例冷启动叠加长时间运行的流式响应,延迟会雪上加霜。这条路我也建议你认真评估,流量大了之后,一个独立的Node服务加PM2或者Docker,可控性和成本都比Serverless好。
5.2 超时与重试:别对LLM调用做无脑重试
Next.js Route Handler在Vercel Hobby计划下默认超时10秒,Pro计划是60秒。简历Agent的完整流程经常超过10秒,所以我把超时调成了60秒,同时给模型调用加了指数退避重试。
这里有个坑:不要对LLM调用做无脑重试。要区分rate_limit和bad_request——前者是请求太频繁,重试有效;后者是Prompt格式有问题,重试只会浪费token和时间。我在provider封装里加了错误分类:
import { isRetryableError } from "@/lib/errors"; async function callWithRetry(fn, maxRetries = 3) { for (let i = 0; i < maxRetries; i++) { try { return await fn(); } catch (error) { if (!isRetryableError(error) || i === maxRetries - 1) throw error; const delay = Math.pow(2, i) * 500 + Math.random() * 200; await sleep(delay); } } }指数退避的基数我用的500ms,最大重试次数3次。实测在OpenAI的rate_limit场景下,这个配置能把成功率从92%拉到99.5%,而额外的平均等待时间只有不到2秒。
5.3 token成本控制:大模型只用在刀刃上
一次完整流程(解析+诊断+改写)在我的配置下大约消耗12k到30k tokens。这个成本对普通用户来说不算低,按1000个用户每天三次使用估算,每个月光模型费用就要几百美元。所以成本控制是产品化绕不开的课题。
我的控制策略有四条:
第一,解析和诊断用gpt-4o-mini,改写才用gpt-4o。一次流程里解析大概3k token,诊断4k token,改写8k到15k token。如果都用大模型,成本直接翻倍还多。
第二,简历文本做截断。超过3000字的文本分块处理,每次只把相关的段落传给模型。分块逻辑要谨慎,最好按照工作经历和项目经历的自然段落来切,别硬切,否则上下文语义会碎。
第三,诊断结果做缓存。同一份简历的第二次诊断直接读缓存,用户改了简历某一段之后,只重新诊断被改的段落。
第四,流式输出时前端做节流渲染。这个虽然不是成本优化,但能减少首屏等待的焦虑感,间接提升用户满意度。
5.4 输出质量校验:AI不稳定的兜底方案
AI输出不稳定是绕不开的。我的做法是加一个校验层:改写结果必须包含公司名、职位、时间这些关键字段,如果模型改丢了一个,就触发一次修正调用。
// src/agent/validation/validateRewrite.ts export function validateRewrite(original, rewritten) { const requiredFields = ["company", "position", "startDate", "endDate"]; return requiredFields.filter((field) => { const originalValue = original[field]; const rewrittenValue = rewritten[field]; return originalValue && !String(rewrittenValue).includes(String(originalValue)); }); }实测下来,这个兜底能拦截掉大约8%的坏输出。8%听起来不多,但对一个工具产品来说,这8%的用户体验直接决定口碑。另外还有一个细节:诊断和改写结果都建议做JSON Schema校验,字段类型不对就直接标记失败,不要让前端拿到脏数据。
6. 扩展方向与实操体会
6.1 checkpoint持久化:让Agent记住每个用户
LangGraph.js的checkpointer可以接到Redis或者Postgres上,这样用户刷新页面之后Agent还能记得之前的诊断结论,不用重新跑一遍。我目前只做了内存版(InMemorySaver),生产环境建议直接上Redis版。
import { RedisSaver } from "@langchain/langgraph"; const saver = RedisSaver.init({ host: process.env.REDIS_HOST, port: Number(process.env.REDIS_PORT), }); const graph = buildResumeAgent().compile({ checkpointer: saver });这一步做完之后,Agent才真正从"无状态函数"变成了"有状态助手",每个threadId对应一个独立的会话档案。
6.2 从单Agent到多Agent的演进思路
我的下一步计划是把诊断和改写拆成两个独立的Agent,中间通过消息队列传递结果。这样能并行处理多个用户的简历,吞吐量会比单Agent串行高很多。前端那边其实感知不到这个变化,因为API协议可以保持不变,只是内部的服务编排从"单线程流图"变成了"分布式任务流"。
如果你真的要做多Agent,建议先想清楚消息传递的载体和失败补偿机制。Diagnosis结果丢了可以重新生成,但rewrite结果丢了用户会骂人——所以这两个任务的可靠性等级是不一样的。
6.3 我个人的实操体会
最后说几句大实话。用LangGraph.js写Agent,最大的感受是它把"流程"这个概念真正引入到了LLM应用里。状态、节点、边都是显式定义的,出了问题可以逐步debug——哪个节点输出了脏数据、哪条边跳错了方向、哪个状态字段没更新,都能从图里扒出来。这比纯Prompt调优那种黑盒体验要舒服太多。
但代价是学习曲线比单纯调Prompt陡一些。一开始接触Annotation.Root、reducer、addConditionalEdges这些概念时,确实有点绕。我的建议是别一上来就写复杂的图,先做一个"解析到输出"的两节点最小闭环,跑通了再加条件分支,再加checkpoint,一层层往上面长。等状态设计清晰之后,后面一切迭代都顺了。
还有一个非常实际的建议:日志一定要打好。每个节点的输入输出都打结构化日志,带上threadId和时间戳。Agent排错比普通接口排错难多了,因为问题可能出在模型输出、节点逻辑、状态合并任意一环。有一次我排查了整整一个下午,最后发现是某个节点的返回值少了个字段,导致下一步节点拿到的状态不完整。如果日志从一开始就埋好,这种问题十分钟就能定位。