☰
用LangGraph.js搭建简历AI Agent:架构、Token优化与部署实践
2026/10/8 20:56:48 网站建设 项目流程

最近被问得最多的问题不是“你用了什么大模型”,而是“Agent 到底怎么才能不飘”。我干脆把手头这个简历工具翻了出来,把端到端落地的全过程摊开写一遍——从 Next.js 搭前端,到 LangGraph.js 编排多步骤 AI Agent,再到一个能真正干活、而不是大模型聊天套壳的简历 AI 助手。这篇文章不绕弯子,全是实际项目里抠出来的经验,包括架构怎么定、节点怎么拆、Token 怎么省、部署卡在哪,以及我踩过的几个大坑。适合正在做 Agent 落地、想搞懂 LangGraph.js 状态图、或者准备把 AI 能力产品化的开发者,看完可以直接把这一套搬到自己的场景里。

1. 项目概览与整体架构

1.1 为什么选“简历工具”这个场景

做 AI Agent 最容易犯的毛病,是选一个听起来炫酷但边界模糊的场景,比如“通用智能助手”,然后被各种意图分支拖死。简历工具不一样,它的任务链路非常清晰:用户上传简历,系统解析内容,然后做结构化评估、生成面试问题、给出优化建议、输出最终报告。每一步都有明确的输入输出,天然适合用图来编排。

更重要的是,简历场景有真实用户需求。求职者想知道“我的简历哪里弱、面试官会问什么”,HR 想快速筛出一份简历的亮点和风险点。只要 Agent 输出的报告够专业,就能直接做成一个小工具产品,甚至能扩展成招聘场景下的批量评估服务。这个场景不需要编造需求,用户痛点本来就存在。

另外,简历文本是中等长度文档,不会像小说那样动辄几十万字,也不会像单句指令那样信息量太少。这个量级对 Agent 的上下文管理、Token 预算控制、分块提取策略都有很好的锻炼价值,做完一个简历 Agent,换到合同分析、论文摘要、客服工单分类等场景,核心思路可以平移。

1.2 技术选型:为什么是 Next.js + LangGraph.js

选 LangGraph.js 而不是 LangChain.js、CrewAI 或者自己写状态机,是我做完这个项目后最有底气的一个决定。LangChain.js 擅长做“链”,也就是把 Prompt、模型、工具串成一条直线,但简历评估这类任务不是一条直线,它有分支:评估分数低了要回炉优化,分数达标了才生成最终报告。这种带条件分支和循环的逻辑,LangChain.js 写起来要靠一堆 if-else 在外部硬控,代码很快会乱。

LangGraph.js 的核心是 StateGraph,把 Agent 的每一步抽象成节点,节点之间用边连接,边上可以加条件判断。这就像一个流程图,思维过程是显式的,调试的时候能单步看状态。它还有两个对生产环境特别重要的能力:持久化和人机协同。checkpointer 可以把每一步状态存下来,用户刷新页面还能恢复会话;interrupt 可以暂停图执行,等着用户确认某个中间结果之后再继续。这对简历优化场景太关键了,AI 给出的修改建议不应该直接改掉用户的原文件,而是先让用户确认再走下一步。

CrewAI 我也试过,它在多角色协作上有自己的优势,比如让“HR 专家”和“面试官”两个 Agent 互相配合,但对简历这种目标单一、步骤固定的任务来说,角色协作反而增加了调度复杂度。自研状态机更不用提了,看着简单,一旦加上流式输出、多轮会话、人机交互,工作量会直线上升,等于重新造一个轮子。

前端选 Next.js 基于两点:一是 App Router 加 API Route 能把前后端放在一个项目里,简历上传、Agent 调用、结果下载全在一个应用内闭环,部署运维成本低;二是 Next.js 对流式响应支持成熟,Agent 执行过程可以通过 SSE 推给前端实时渲染,体验比后端跑完再一次性返回好太多。如果你需要和别的系统深度集成,可以只把 Agent 部分拆成独立服务,但起步阶段一个全栈项目足以覆盖所有需求。

1.3 整体架构与数据流

整个系统可以简化成一条直线加一个分支:

上传简历 → parse_node 解析文件 → analyze_node 多维度评估 → generate_questions_node 生成面试问题 → score_node 评分 → 条件边判断“是否达标” → 达标走 final_node 生成报告,不达标回到 revise_node 重新优化 → 再次评分,直到达标或达到最大迭代次数。

前端部分拆成三个区域:上传区接收 PDF、DOCX 或纯文本;过程面板实时展示当前执行到哪个节点,每个节点有 pending、running、done 三种状态;结果区展示最终评估报告,并提供 Markdown 下载按钮。

这里有一个容易被忽略的关键点,就是“解析”和“分析”必须分成两个节点。一开始我想省事,把 PDF 解析和 LLM 分析放在同一个节点里,结果发现问题非常难排查:文件解码失败时,你根本分不清是 PDF 库的问题还是模型理解的问题。拆开之后,parse_node 是纯本地逻辑,只负责把文件变成干净的文本;analyze_node 才是 LLM 的活。这样每一段都可以单独测试,出了问题也能快速定位到具体环节。

2. 核心细节:状态建模与节点设计

2.1 状态(State)用 Annotation 怎么定义最舒服

LangGraph.js 的状态类型直接用 Annotation 定义,相当于给整个图的“共享内存”画了一张表。我的定义大致是这样的:

import { Annotation, messagesStateReducer } from "@langchain/langgraph"; import { BaseMessage } from "@langchain/core/messages"; export const AgentState = Annotation.Root({ fileName: Annotation<string>, resumeText: Annotation<string>, messages: Annotation<BaseMessage[]>({ reducer: messagesStateReducer, }), analysisResult: Annotation<string>, questions: Annotation<string[]>, score: Annotation<number>, iteration: Annotation<number>({ reducer: (left) => (left ?? 0) + 1, }), finalReport: Annotation<string>, });

最关键的是 reducer 字段。如果不写 reducer,每个节点写入该字段时都会直接覆盖之前的值;但 messages 这种对话历史需要不断追加,所以要用 messagesStateReducer,类似把新旧数组 concat 起来。用 React 来类比就是 useReducer,每个节点好比一个 dispatch,reducer 决定这次更新是覆盖还是合并。

iteration 的 reducer 我写了一个自增函数,每次调用就在原值上加一。这样不需要在节点里手动读旧值再写回新值,直接在节点结尾 return 的时候给 iteration 赋一个新对象就行,reducer 会自动累加。这个小细节让代码干净不少,也避免了并发更新时的状态丢失。

2.2 节点编排与条件分支的设计要点

每个节点本质上是一个函数,接收当前状态,返回部分状态更新。我把简历 Agent 拆成五个节点:

  • parse_node:把上传的 PDF/DOCX/buffer 转成纯文本,做长度裁剪。
  • analyze_node:LLM 从教育背景、技能栈、项目经验、亮点与风险几维度输出结构化分析。
  • generate_questions_node:基于分析结果生成面试题目,存放进 questions。
  • score_node:给简历综合打分,并判断是否值得生成最终报告。
  • final_node:汇总分析、问题、建议,生成一篇完整 Markdown 报告。

节点之间默认是顺序执行的,真正让它“活”起来的是条件边。我在 score_node 后面加了 addConditionalEdges,判断逻辑类似:如果 score 低于 70 且 iteration 没有超过上限,回到 analyze_node 重新评估;如果达标或者已经迭代了三次,就跳转到 final_node。

graph.addConditionalEdges("score_node", (state) => { if (state.score < 70 && state.iteration < 3) { return "analyze_node"; } return "final_node"; });

这个分支逻辑是整个 Agent 的运作灵魂。没有它,简历工具就只是一个“读一遍然后输出报告”的包装壳。有了它,系统才能真正地对低分简历进行定向优化,比如指出“项目描述缺少量化指标”,然后带着这个改进意见重新走一轮分析。

这里有个非常值得注意的点:recursionLimit 是图执行的深度上限,一定要在编译图的时候设置一个合理值。我把上限设成 25,目的是防止条件边写错之后 Agent 陷入死循环,变成不断调用 LLM 烧 Token。设置这个上限等于给整个执行过程买了一份额外的保险。

2.3 人机协同怎么做才不打断体验

简历工具里最有价值的一个交互,是 AI 生成优化建议后,不让它直接改用户简历,而是暂停图执行,把建议方案展示给用户,等用户点“确认应用”再继续。

LangGraph.js 做这个是用 interrupt_before。在图编译配置里指定 interrupt_before 为某个节点,比如 revise_node,那么执行到该节点之前图会自动挂起,把控制权交回应用层。应用层拿到挂起状态之后,可以通过 thread_id 继续恢复这个图,往里面传入用户的选择,让流程接着跑下去。

const config = { configurable: { thread_id: sessionId } }; // 用户确认后继续执行 await graph.invoke(null, config);

这个设计最大的好处是让 AI 不显得“自作主张”。简历是个人重要文档,用户对自动修改天然有警惕心。人机协同把每一步关键决定都放在用户手里,既展示了 AI 的能力,又保留了用户的把控感。实际体验下来,用户对“建议-确认-执行”这套流程的接受度,比对“AI 一键改完”要高很多。

suspended 状态在前端要做对应处理,过程面板要提示用户“AI 正在等待确认”,然后显示具体的优化建议和两个按钮。不能只是一个干巴巴的加载 spinner,否则用户会以为系统卡住了。

3. 实操:从零把 Agent 跑起来

3.1 初始化项目与依赖安装

创建一个 Next.js 项目不需要纠结配置,直接用脚手架默认选项就行。我的命令是这样的:

npx create-next-app@latest resume-agent cd resume-agent npm install langgraph @langchain/core @langchain/openai pdf-parse mammoth

langgraph 是核心图编排框架,@langchain/core 提供消息类型和工具接口,@langchain/openai 是我接模型用的 SDK,pdf-parse 和 mammoth 分别负责解析 PDF 和 DOCX 文件。

模型供应商的选择比较开放。项目里我写了一个 getModel 函数,通过环境变量切换不同服务商,这样同一套图逻辑既能跑本地开源模型,也能切换云厂商的模型接口。实际测试下来,评估这类任务对模型的要求主要是长文本理解能力和结构化输出稳定性,不强求对话花样,选性价比高、上下文够大的模型即可。

3.2 后端 API 与图编译

Agent 的入口我放在 app/api/agent/route.ts。这个接口接收 multipart/form-data,从表单里拿到简历文件,转成 buffer 之后调用 parse_node 的解析函数,拿到文本后再把整个图跑起来。

import { NextRequest } from "next/server"; import { graph } from "@/lib/resumeGraph"; import { v4 as uuid } from "uuid"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function POST(req: NextRequest) { const formData = await req.formData(); const file = formData.get("file") as File; const buffer = Buffer.from(await file.arrayBuffer()); const config = { configurable: { thread_id: uuid() }, recursionLimit: 25, }; // 先把文件解析成文本,再进入图执行 const result = await graph.invoke( { fileName: file.name, resumeText: await extractTextFromFile(file.name, buffer), }, config ); return Response.json(result); }

图本身的定义和节点函数都放在 lib/resumeGraph.ts 里。编译图的时候我先 addNode 注册所有节点,再添加默认边和条件边,最后 compile。有一个很容易漏掉的点是,需要在 compile 时传入 checkpointer,否则 thread_id 无法生效,多轮恢复、interrupt 全都用不了。我最初就吃了这个亏,排查了半天才发现只是忘了传 checkpointer。

提取文本时要注意中文 PDF 兼容性。pdf-parse 对标准字体编码的 PDF 表现不错,但遇到内嵌子集字体或者扫描件,很容易抽出乱码。我会在 extractTextFromFile 里做一段长度校验,如果解析出的文本有效字符占比过低,就返回错误信息让用户改用文本上传。这个方法虽然土,但很实用,能挡住相当一部分“文件解析成功但全是乱码”的尴尬问题。

3.3 前端流式消费与过程面板

简历 Agent 跑一轮需要调用多次 LLM,如果全部跑完再返回,用户至少等十几秒,体验很差。我的解决方案是让 API 返回 SSE 流,前端边读边渲染,过程面板能实时看到每个节点从运行到完成的变化,LLM 生成的每个 token 也会逐步出现在结果区。

前端消费流的核心代码思路是这样:

const response = await fetch("/api/agent", { method: "POST", body: formData, }); const reader = response.body?.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value); const lines = chunk.split("\n"); for (const line of lines) { if (line.startsWith("data:")) { const event = JSON.parse(line.slice(5)); handleAgentEvent(event); } } }

SSE 消息体我约定为统一 JSON,包含 type 和 data 两个字段。type 为 node 时,data 里是节点名和新的状态;type 为 token 时,data 是 LLM 生成文本的增量片段。前端拿到之后分别更新过程面板和结果区。

这里有个非常影响体验的小细节:节点状态的更新要用 replace 而不是追加。过程面板上每一个节点对应一个小卡片,卡片里有一个状态灯。如果我用追加方式更新,刷新一次就多一个节点卡片,几十轮跑下来卡片会铺满屏幕。改成按节点名做映射更新之后,过程面板始终只有五个固定卡片,状态灯从灰色变黄色再变绿色,视觉上非常清晰。这个设计让用户对整个 Agent 的执行过程一目了然,而不是面对一串猛然出现的大文本。

3.4 部署时要注意的坎

部署这一层我建议别想得太简单。常规的 Serverless 平台对长耗时任务有限制,简历 Agent 即使做了流式输出,整个任务算上解析、两次到三次 LLM 调用,依旧可能超过默认函数时长的限制。我实测在默认配置下经常在两次模型调用之间被强制断开连接,客户端收到一个不完整的流。

我的对策是把请求路由到 nodejs runtime,同时在打包时缩小镜像体积、避免用体积敏感的大型依赖。要是 Agent 流程更长,那基本得考虑常驻容器方案,容器里跑一个 Node 服务,专门承接 Agent 任务,Next.js 只管前端和轻量 API,两者之间通过内部服务通信。这样既保住了 Next.js 的静态站点优势,又不让 Agent 任务被平台超时掐死。

手动部署时记得把模型服务的密钥放到环境变量里,不要在代码仓库里出现任何明文。家丑不外扬,我见过直接把 key 打在 .env 里然后推到公开仓库的,那基本等于给路由开了后门。

4. Token 预算、成本控制与结构化输出

4.1 一次说清楚 Agent 里的 Token 是什么

很多人刚接触 Agent 时会问“Token 到底是什么意思”。简单来说,Token 是大模型处理文本的最小切分单元,中文通常一个汉字对应一到两个 Token,英文一个单词往往是一个或两个 Token,Token 数量直接决定了调用成本。如果拿乐高积木来类比,Token 就是积木块,模型用无数块积木搭出你对它的指令,再搭出它的回复,每一块都要计费。

简历 Agent 很容易在 Token 上超支,原因有几个。简历转成文本后动辄三四千字,对应五六千 Token 的输入;多轮迭代时,上一轮的分析结果和优化建议都要作为下一次调用的上下文,Token 会像滚雪球一样越滚越大;再加上系统提示词、工具返回结果,一次完整流程跑下来,总消耗可能达到一到两万 Token。如果不做预算控制,一个免费工具可能被几个重度用户薅穿。

控制 Token 的第一道防线是输入裁剪。简历文本超过八千字时,我会截断保留前面的教育背景和项目经验,因为这两部分对评估价值最高,后面的自我评价和兴趣爱好可以舍弃。第二道防线是设置 max_tokens,防止模型“话痨”,把单轮输出控制在合理长度。第三道防线是合理选择模型,对不同任务分级使用不同模型,解析提取用轻量模型,深度评估用强模型,成本可以明显降下来。

4.2 提示词与结构化输出设计

简历 Agent 的提示词我采用三段式:角色设定、任务描述、输出格式要求。系统提示词里的角色是一个“资深技术面试官和简历顾问”,任务描述是“根据简历内容从教育背景、技能栈、项目经验、亮点与风险五个维度进行评估”,输出格式要求是“给出 score 分数、summary 摘要、suggestions 建议列表”。

三段式的好处是稳定。模型拿到角色之后行为模式会比较一致,明确任务让输出聚焦不跑题,格式要求则保证了后续节点可以稳定解析。复杂的分析结果不应该让模型自由发挥,而应该要求它输出 JSON。我推荐用响应格式结构化输出的方式而不是手写 JSON.parse,这样模型直接按 schema 返回 JSON,省去自己拼提示词和解析异常的麻烦。

const model = getModel().bind({ response_format: { type: "json_object" }, max_tokens: 2048, });

即使做了结构化输出,我在代码里依然会做一层 try/catch 兜底,解析失败时自动退化成 JSON 字符串截取 + 手动提取字段的逻辑。这是被现实教育出来的,模型偶尔会抽风,你必须得容错,否则一个模型层的偶发错误就会让整个 Agent 流程中断。

4.3 预算怎么算才靠谱

给 Agent 定预算不能靠拍脑袋。我建议直接按一次完整流程图来估算 Token 消耗。我项目的实测数据是这样的:

环节输入 Token输出 Token说明
语言模型解析简历4000800文本截断后输入
面试问题生成2500600基于分析结果
评分判断2000200短输出即可
优化迭代50001200仅低分触发
最终报告30001000汇总全部信息

以上模型估算,单份简历的全流程消耗约 1.6 万到 1.8 万 Token。按模型价格折算下来,单份成本从几分到几毛不等。月调用量乘一下就得出预算,再留出 30% 的余量给异常情况和模型返回超长。

成本优化还有一个容易忽视的入口是缓存。同一份简历如果用户重复请求,解析结果和分析结论都是相同的,完全可以在第一次跑完后把结果写入缓存,第二次直接命中,不再走图、不再调用模型。好的工具不只是功能好用,后台花销也得省着点,不然用着用着就亏了。

5. 常见问题与踩坑实录

5.1 问题排查速查表

症状可能原因解决方案
PDF 解析出来全是乱码内嵌字体编码不支持或扫描件改纯文本上传,或用带 OCR 能力的前置服务
前端流式输出丢字客户端没有正确拼接多 chunk检查 TextDecoder 和流的边读边拼逻辑
Agent 循环跑不停条件边判断逻辑写错检查 addConditionalEdges 返回值,加上 recursionLimit 兜底
多轮会话无法恢复编译图时没传 checkpointercompile 时配置 MemorySaver 或 RedisSaver
模型返回 JSON 解析失败没启用结构化输出或模型抽风启用 response_format,parse 时加兜底
Serverless 函数超时任务链路太长切 nodejs runtime,或使用常驻容器服务

5.2 三个让我失眠的坑

第一个坑是中文 PDF 解析乱码。我一开始用纯 JS 的 PDF 解析库,英文简历测试非常顺利,一到中文简历就一堆乱码。折腾了半天才发现,pdf-parse 对中文字体子集支持有限,扫描版 PDF 更是完全拿不到文字层。最后的解决方式是三管齐下:优先尝试 PDF 解析,解析失败提示用户改用文本或 DOCX;对文本质量做有效字符占比检测,挡住乱码数据进入下游;真需要硬啃扫描件,再接入专门的 OCR 服务,而不是指望一个前端解析库搞定。

第二个坑是流式输出在 Serverless 平台被掐断。本地一切正常,部署上线后用户反映“输出到一半就不动了”。查了很久发现是消息处理器对长连接设置了空闲超时,简历 Agent 涉及多次模型调用,中间间隔超过阈值连接就断了。解决思路有两个方向,一个是把每次模型调用的输出尽快刷给客户端,减少空闲时间;另一个是干脆把 Agent 任务迁到不受平台超时限制的部署上。这个坑提醒我一件事,本地跑通只是第一步,生产环境的长连接治理要提前想好。

第三个坑是条件边逻辑写错导致的死循环。我最初希望在分数低于 70 时不断优化,结果写完没做边界检查,agent 反复调用模型,Token 消耗肉眼可见地飙升。后来在条件边里加了迭代次数上限,超过三次强制走 final_node,又把 recursionLimit 设成 25 兜底,系统才稳定下来。这个经验让我养成了一个习惯:凡是带循环分支的 Agent,一定要有明确的终止条件,而且要在图上设置硬性上限,不能用“模型应该不会一直循环”来赌。

5.3 调试技巧与离线验证

开发 Agent 最痛苦的是每次都要上传简历、等真实模型响应,调试一次成本太高。我的做法是准备一份固定的测试简历文本放在 tests/fixtures 里,写 Jest 用例直接调用图引擎跑完整流程。离线跑图可以断言每个节点是否正常执行、返回的状态是否符合预期,这一层逻辑验证不依赖网络和模型,写代码时随时可以回归。

线上问题则靠观测工具。我给整个图接上事件回调,在每一个节点进出时打点记录耗时,部署后通过日志平台查看每个节点的时间分布。哪个节点慢一眼就能看出来。如果总耗时突然变长,往往不是图逻辑变了,而是模型服务变慢或网络抖动,这时候要去检查外部依赖的可用性,再决定是否加超时重试。调试 Agent 和调试传统后端有一个重要区别:传统后端主要查错误堆栈,Agent 要重点关注状态节点之间的流转是否符合预期,需要把图上每一步都“可视化”出来。

6. AI Agent 主流架构与学习路线

6.1 主流 Agent 架构到底怎么选

做 AI Agent 绕不开架构选型,我拿自己的横向对比经验说几句。LangGraph 是图编排派,显式控制节点和状态流转,适合任务链路清晰、需要稳定落地的场景,比如简历工具、数据分析流程、客服工单处理。CrewAI 是角色协作派,让多个 Agent 扮演不同角色互相配合,适合“讨论型”或“分工型”任务,但调度成本高,不确定性强。AutoGPT 这类自由规划派会自动拆解目标去调工具,看起来极具未来感,但实际上很容易跑飞,任务一复杂就开始逻辑混乱,我建议只在实验项目里玩。

这里额外回应一个最近大家都关心的方向,基于 Rust 的 Agent 框架。Rust 生态里确实出现了一些轻量、注重性能和资源占用的 Agent 框架,它们的设计理念对运行环境要求严格的场景很有吸引力,比如边缘部署或高并发请求。但它目前的工具链、社区生态和最佳实践沉淀都比不上 JS/TS 或 Python 体系,我自己评估下来,除非有明确的性能瓶颈,否则纯业务项目暂时没必要激进地用 Rust 搭 Agent,等生态再成熟一些会更好。

6.2 给想学 Agent 的新手一条路线

我把我的学习路径压缩成四个阶段。第一阶段是把 LLM 的基础概念吃透,Token、上下文窗口、temperature、结构化输出,这些是地基中的地基。第二阶段是把 LangChain 类的工具链用顺手,搞明白 Prompt 模板、模型调用、工具调用的基本写法。第三阶段直接上 LangGraph,从一个小场景开始,比如做一个“用户意图识别 → 回答问题 → 复杂问题转人工”的双节点 Agent,把状态、节点、条件边跑通。第四阶段做持久化与人机协同,加一个用户确认环节,再把整个链路部署上线,加上日志观测,这时候才算真正具备 Agent 落地的能力。

这个路线最反直觉的一点是:不要一上来就搜集一堆框架和开源项目。Agent 的核心不是工具库本身,而是把任务拆解成节点、把节点粘成流程、把流程稳定跑起来的工程能力。工具库都是辅助手段,问题拆解能力才是真正值钱的背景。

最后

我在实际项目里最有感触的一点是,Agent 不是“越大越智能”,而是“流程越清晰越好用”。这一套简历工具如果丢给通用 Agent 自由发挥,它也能给你一个答案,但不会稳定、可控、可解释。用 LangGraph.js 把每一步拆开之后,系统变成了可以调试、可以计费、可以扩展的工程产品,这才是 AI 落地该有的样子。

最后再分享一个小技巧:本地开发时准备三份固定简历做回归测试,一份优秀简历、一份普通简历、一份格式混乱的简历。每次改完节点逻辑就跑一遍这三个用例,确保不会修好一个分支又搞坏另一个分支。这个习惯救过我太多次了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询