news 2026/11/5 18:10:42

Next.js + LangGraph.js 实战:构建 AI Agent 简历优化工具

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Next.js + LangGraph.js 实战:构建 AI Agent 简历优化工具

简历工具这个赛道,表面上看已经被做烂了,但真正动手做一个能用的 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 一定要版本化管理,每次调整都记录改了什么、效果如何,否则很快就会忘记哪个版本效果最好。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/31 7:36:41

从信息洪流到每日必读:AI日报制作全流程与工具链拆解

1. 一份AI日报的诞生&#xff1a;从信息洪流到每日必读每天早上八点&#xff0c;我的手机都会准时弹出一份自己做的AI日报。这个习惯从2024年一直坚持到现在&#xff0c;中间迭代了十几个版本&#xff0c;从最初手动复制粘贴的粗糙文档&#xff0c;到现在半自动化的信息流水线&…

作者头像 李华
网站建设 2026/10/31 19:24:50

Orca开源ADE:并行AI代理管理与工作区隔离实战

1. 从“单线程”到“多线程”&#xff1a;为什么我们需要并行 AI 代理管理 如果你最近半年一直在折腾 AI 代理&#xff0c;大概率会经历这样一个阶段&#xff1a;一开始觉得一个聊天窗口就能解决所有问题&#xff0c;后来发现写代码要一个代理、查资料要一个代理、跑测试要一个…

作者头像 李华
网站建设 2026/11/5 7:24:19

SPEED2000眼图仿真实战:从原理到抖动分析的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/3 7:19:25

基于yolov5的步态识别多目标跨镜头跟踪系统解析与实战

简介&#xff1a;面向人工智能与计算机视觉方向的本科生、研究生及算法研究者&#xff0c;这套基于YOLOv5与DeepSort框架的步态识别多目标跨镜头跟踪检测系统源码&#xff0c;完整覆盖目标检测、多目标跟踪和步态特征识别三大核心环节&#xff0c;直接对应毕业设计课题“基于步…

作者头像 李华