ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Next.js + LangGraph.js 实战:构建 AI Agent 驱动的智能简历优化工具

Next.js + LangGraph.js 实战:构建 AI Agent 驱动的智能简历优化工具 1. 为什么我要用 Next.js LangGraph.js 重写简历工具简历工具这个赛道看起来已经被做烂了。市面上一抓一大把在线简历生成器模板套一套、PDF 导一导好像没什么技术含量。但真正做过的人都知道用户要的从来不是填表导出这么简单——他们想要的是有人帮我把经历写得更专业、更匹配目标岗位、更能在几秒钟内抓住 HR 的眼球。这就是我决定用 Next.js 搭配 LangGraph.js 做一套 AI Agent 驱动简历工具的起点。先把这个项目的定位说清楚它不是又一个简历模板站而是一个能对话、能推理、能分步骤打磨简历的智能体应用。用户上传或手填基础信息后Agent 会主动追问项目细节、量化成果、识别关键词缺口最后输出一份针对特定 JD 优化过的简历。适合谁看前端想入门 AI 应用的同学、全栈想补 Agent 编排能力的开发者、以及正在做求职类产品的独立开发者。哪怕你之前只写过 CRUD只要熟悉 React 和一点 Node这篇的落地路径你都能照着走。我选 Next.js 而不是纯 Vite SPA核心原因是这个产品天然需要服务端能力调用大模型 API 不能把密钥暴露在浏览器、简历解析要跑在服务端、流式输出需要 Edge/Node Runtime 支持。Next.js 的 App Router 把 Server Component、Route Handler、Server Action 揉在一起正好覆盖了页面渲染 后端接口 流式响应三件事不用再单独起一个 Express。而 LangGraph.js 解决的是另一个更棘手的问题多步骤、有状态、可回退的对话流程。普通的一次性 prompt 调用用户改一句就得从头再来体验极差LangGraph 用图结构把信息收集→内容优化→关键词匹配→格式输出拆成节点状态在节点间流转用户随时能回到某一步重做。这里有个很多人踩的坑一上来就用最火的 Agent 框架堆功能结果状态管理一团乱。我的建议是先把状态机想清楚再写代码。简历优化的流程本质是线性的但中间有分支比如用户没有项目经历时走挖掘校园/自学经历分支这种主干清晰、局部有分支的场景恰恰是 LangGraph 最擅长的。下面我会把整套设计思路、核心代码、踩坑记录全部摊开讲。2. 整体架构设计与技术选型拆解2.1 为什么是 Next.js App Router 而不是 Pages RouterApp Router 和 Pages Router 最大的区别在于数据获取和渲染的边界。简历工具里有一类页面是纯静态的落地页、模板预览有一类是强交互的编辑器、对话面板还有一类是纯接口调用 LLM、解析文件。App Router 允许我在同一个项目里用 Server Component 渲染静态部分、用 Client Component 处理交互、用 Route Handler 暴露 API三者共享 TypeScript 类型定义不用维护两套工程。具体到目录结构我是这么组织的app/ (marketing)/page.tsx # 落地页Server Component editor/[id]/page.tsx # 编辑器Client Component api/ chat/route.ts # 流式对话接口 parse/route.ts # 简历文件解析 export/route.ts # PDF 导出 lib/ agent/ graph.ts # LangGraph 图定义 nodes/ # 各节点实现 state.ts # 状态类型定义 llm/ client.ts # 模型客户端封装注意Route Handler 默认是静态的涉及 LLM 调用的接口必须显式声明export const dynamic force-dynamic否则构建时会被预渲染运行时直接报错。这个坑我在第一次部署时踩了整整一个下午。2.2 LangGraph.js 相比直接调 API 的价值在哪很多人会问不就是调个模型吗为什么要引入 LangGraph 这么重的依赖我用一个具体场景回答。假设用户说帮我把这段项目经历写得更牛如果直接调 API你得自己拼 prompt、自己判断模型输出是否合格、自己处理用户不满意要重写的情况。而 LangGraph 把这些都抽象成了节点 边 条件路由节点Node一个处理单元比如提取关键信息生成优化版本检查关键词覆盖边Edge节点间的流转可以是固定的也可以是条件判断的状态State贯穿整个图的数据结构每个节点读取并更新它最关键的是**检查点Checkpointer**机制。LangGraph 支持把每一步的状态持久化这意味着用户刷新页面、关掉浏览器再回来对话还能接着上次继续。对于简历这种用户会反复修改、断断续续打磨的场景这个能力是刚需不是锦上添花。2.3 状态设计整个 Agent 的骨架状态设计错了后面全是返工。我最终的状态结构是这样的// lib/agent/state.ts import { Annotation } from langchain/langgraph; export const ResumeState Annotation.Root({ // 对话消息历史 messages: AnnotationBaseMessage[]({ reducer: (x, y) x.concat(y), default: () [], }), // 简历结构化数据 resumeData: AnnotationResumeData({ reducer: (x, y) ({ ...x, ...y }), default: () ({}), }), // 目标岗位 JD targetJD: Annotationstring({ reducer: (_, y) y, default: () , }), // 当前处理阶段 stage: AnnotationStage({ reducer: (_, y) y, default: () collecting, }), // 关键词匹配结果 keywordGaps: Annotationstring[]({ reducer: (_, y) y, default: () [], }), });这里有个设计要点messages用concatreducer 是因为对话是累加的而resumeData用合并 reducer 是因为每次可能只更新部分字段。reducer 的选择直接决定了并发更新时的行为选错了会出现数据覆盖或重复。我一开始把resumeData也写成覆盖式结果用户补充一个技能就把之前的项目经历冲掉了排查了半天才发现是 reducer 的问题。3. 核心节点实现与实操要点3.1 信息收集节点让 Agent 学会追问信息收集节点是整个流程的入口也是最容易被做废的一环。很多实现就是丢一个表单让用户填那不叫 Agent那叫问卷。真正的 Agent 应该根据已有信息判断缺什么然后有针对性地追问。我的实现思路是先解析用户输入抽取结构化字段然后对照一份完整简历需要哪些字段的清单找出缺失项生成追问。核心代码如下// lib/agent/nodes/collect.ts export async function collectNode(state: typeof ResumeState.State) { const lastMessage state.messages[state.messages.length - 1]; // 用结构化输出抽取字段 const extractor llm.withStructuredOutput(ResumeSchema); const extracted await extractor.invoke([ new SystemMessage(从用户输入中抽取简历字段没有的字段留空), lastMessage, ]); const merged { ...state.resumeData, ...extracted }; const missing findMissingFields(merged); if (missing.length 0) { return { resumeData: merged, stage: optimizing }; } // 生成追问一次只问一到两个避免用户疲劳 const question await generateQuestion(missing.slice(0, 2), merged); return { resumeData: merged, messages: [new AIMessage(question)], stage: collecting, }; }实操心得追问一定要一次只问一到两个问题。我最初让模型一口气列出所有缺失字段用户看到七八个问题直接关页面了。改成先问最关键的答完再问下一个之后完成率明显提升。这跟做用户调研是一个道理别把用户当填表机器。3.2 内容优化节点STAR 法则的工程化落地内容优化是这个工具的核心价值点。我的做法是把 STAR 法则Situation-Task-Action-Result拆成可执行的 prompt 约束让模型按这个框架重写用户的原始描述。关键在于不能只给模型一句帮我优化那样输出会飘。我会在 prompt 里明确要求每条经历必须包含可量化的结果数字、百分比、规模动词开头避免负责参与这类弱动词控制在两到三行太长 HR 不看const optimizePrompt 你是一位资深简历顾问。请按以下规则重写这段经历 1. 用强动词开头主导、重构、搭建、优化等 2. 必须包含至少一个量化结果如果原文没有基于合理推断补充并标注[待确认] 3. 采用 STAR 结构但不要出现情境任务等字样 4. 控制在 60 字以内 原始描述${rawExperience} 目标岗位${state.targetJD};这里有个细节值得展开量化结果缺失时怎么办。直接编造是绝对不行的会害了用户。我的处理是让模型基于上下文合理推断然后打上[待确认]标记前端高亮显示提示用户核实。比如用户写优化了首页加载速度模型可能输出通过代码分割和资源预加载将首页加载时间从 3.2s 降至 1.1s[待确认]。用户看到后要么确认要么改成真实数字。这个设计既保证了简历的冲击力又守住了诚信底线。3.3 关键词匹配节点JD 对齐的量化实现这个节点解决的是简历投出去石沉大海的问题——很多时候不是能力不够是简历里的词和 JD 里的词对不上过不了 ATS简历筛选系统。我的实现分三步先从 JD 里抽取关键词再从简历里抽取已有词最后算差集。关键词抽取不能简单分词因为 JD 里的熟悉 React 生态和React权重不一样。我用模型做加权抽取const jdKeywords await llm.withStructuredOutput( z.object({ keywords: z.array(z.object({ term: z.string(), weight: z.number().min(1).max(5), category: z.enum([skill, experience, education, soft]), })), }) ).invoke(从以下 JD 中抽取关键词并评估权重1-5 ${state.targetJD});然后跟简历文本做匹配输出缺口列表。前端会把缺口词用醒目颜色标出来并给出建议在项目经历中补充 XX 相关描述的提示。关键词类型权重范围处理策略硬技能React、Python4-5必须覆盖缺失则强提示经验要求3年以上3-4检查年限是否匹配软技能沟通、协作1-2有则加分无则忽略行业词金融、电商2-3结合背景判断相关性注意关键词匹配不要做成堆砌。我见过有人为了过 ATS 把 JD 里的词全塞进简历结果读起来像机器人写的HR 一眼就看出来。正确做法是把关键词自然融入真实经历描述而不是单独列一坨。4. 流式输出与前端交互的完整实现4.1 用 Route Handler 实现流式对话简历优化这种场景用户盯着屏幕等 10 秒才出结果体验是灾难性的。必须做流式输出让文字一个字一个字蹦出来。Next.js 的 Route Handler 配合 Web Streams API 可以很优雅地实现// app/api/chat/route.ts export const dynamic force-dynamic; export async function POST(req: Request) { const { messages, resumeId } await req.json(); const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { const graph await getCompiledGraph(); const events await graph.stream( { messages }, { configurable: { thread_id: resumeId } } ); for await (const event of events) { const chunk JSON.stringify(event); controller.enqueue(encoder.encode(data: ${chunk}\n\n)); } controller.close(); }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }, }); }前端用fetch配合ReadableStream读取逐块解析 SSE 格式。这里有个坑不要用 EventSource因为它只支持 GET 请求而我们需要 POST 传对话历史。用 fetch 手动解析流是更灵活的方案。4.2 检查点持久化让对话能断点续传LangGraph 的检查点机制需要配合一个存储后端。开发阶段我用内存存储生产环境换成 Postgresimport { PostgresSaver } from langchain/langgraph-checkpoint-postgres; const checkpointer PostgresSaver.fromConnString(process.env.DATABASE_URL); await checkpointer.setup(); const graph workflow.compile({ checkpointer });thread_id就是简历的 ID同一个简历的所有对话都挂在同一个 thread 下。用户关掉页面再回来传入相同的thread_idLangGraph 会自动恢复上次的状态。这个能力对简历工具太重要了——用户往往是晚上改一点、第二天再改一点没有持久化的话每次都得重来。实操心得checkpointer.setup()会创建必要的表但只应该在生产环境初始化时跑一次不要每次请求都调。我一开始放在请求处理里导致每次对话都执行一遍建表语句白白增加延迟。正确做法是放在应用启动的初始化脚本里。4.3 前端状态同步的细节处理前端这边我用 Zustand 管理本地状态跟服务端返回的图状态做同步。核心难点是乐观更新用户发消息后立即在 UI 上显示不用等服务端确认。但 Agent 可能会修改resumeData这部分必须等服务端返回才能更新否则会出现数据不一致。我的处理是把 UI 状态分成两层messages走乐观更新resumeData走服务端权威。具体实现上发送消息时先 push 一条用户消息到本地然后监听流式响应收到resumeData更新事件时再合并到本地 store。const sendMessage async (text: string) { // 乐观更新消息列表 set(state ({ messages: [...state.messages, { role: user, content: text }] })); const res await fetch(/api/chat, { method: POST, body: JSON.stringify({ messages: get().messages, resumeId }), }); const reader res.body!.getReader(); const decoder new TextDecoder(); while (true) { const { done, value } await reader.read(); if (done) break; const chunk decoder.decode(value); // 解析 SSE区分消息增量和状态更新 handleChunk(chunk); } };5. 常见问题排查与避坑实录5.1 模型输出不稳定怎么办这是做 AI 应用最头疼的问题。同样的输入模型这次输出 JSON 格式正确下次就多了一段解释文字。我的应对策略有三层第一层是结构化输出。LangChain 的withStructuredOutput配合 Zod schema能在底层强制模型输出符合格式的内容不合法会自动重试。这比自己在 prompt 里写请输出 JSON靠谱得多。第二层是输出校验。即使结构化输出字段内容也可能不合理比如量化结果里出现提升了 10000%这种离谱数字。我会加一个校验节点对数值做合理性检查超范围的打回重生成。第三层是降级方案。如果模型连续几次输出都不合格就返回一个模板化的兜底结果并提示用户手动调整。宁可给一个平庸但可用的结果也不要让用户卡在那里。5.2 长对话导致 token 爆炸简历打磨是个长对话过程聊到后面历史消息可能几千 token每次请求都带上全部历史成本和延迟都受不了。我的做法是滑动窗口 摘要压缩保留最近 6 轮完整对话更早的对话用模型压缩成一段摘要结构化的resumeData始终完整保留因为它是核心状态这样既保证了上下文连贯又控制了 token 消耗。实测下来一个完整的简历打磨流程token 消耗能降低 60% 左右。5.3 常见问题速查表问题现象可能原因排查方向流式输出卡住不动Route Handler 被静态化检查dynamic force-dynamic刷新后对话丢失检查点未配置或 thread_id 变化确认 checkpointer 和 thread_id 一致性状态字段被覆盖reducer 配置错误检查 Annotation 的 reducer 函数模型输出格式错乱未用结构化输出改用 withStructuredOutput Zod部署后接口 500环境变量未注入检查 LLM API Key 和数据库连接串关键词匹配不准JD 抽取粒度太粗调整抽取 prompt增加权重维度独家避坑技巧在开发环境把每次 LLM 调用的输入输出都落盘。我建了一个logs/llm/目录每次调用存一个 JSON 文件包含 prompt、response、耗时、token 数。出问题时直接翻日志比在代码里打断点快十倍。这个习惯帮我定位了至少一半的诡异 bug。5.4 关于成本和性能的取舍最后聊聊钱的事。LLM 调用是实打实的成本一个用户完整打磨一份简历可能触发十几次模型调用。我的优化策略是分级用模型信息抽取、格式校验这类简单任务用小模型便宜快内容优化、关键词匹配这类需要理解的任务用大模型。实测下来整体成本能压到全部用大模型的 30% 左右而质量差异用户基本感知不到。另外能缓存的绝不重复调用。比如 JD 关键词抽取同一个 JD 被多个用户使用时结果是一样的缓存起来直接复用。我用 Redis 做了个简单的缓存层key 是 JD 文本的 hash命中率意外地高。这套东西搭下来从零到能跑通完整流程我大概花了两周。真正卡时间的不是写代码是想清楚状态怎么流转、节点怎么划分。如果你也在做类似的东西我的建议是先在纸上把图画出来把每个节点的输入输出写清楚再动手写代码。LangGraph 这类框架图设计对了代码就是水到渠成图设计错了写多少代码都是返工。
返回列表