帮一个朋友优化简历这件小事,曾经把我逼到差点掀桌。他丢来一份 PDF,说“帮我改成适合投高级前端岗的版本”。我最初的做法很朴素:把 PDF 内容复制进对话框,让大模型给建议。结果它给的建议全是正确的废话——“突出成果、量化数据、精简表达”。我盯着屏幕愣了几秒才意识到:简历优化根本不是一次对话,而是一条流水线——解析 PDF 里的乱格式、拆解目标 JD 里的硬性条件、逐条诊断经历描述的薄弱点、重写、压缩、排版、导出。这条流水线里任何一步都可能出错、需要回退、需要等用户确认。这正是 AI Agent 的典型场景,不是单纯堆 prompt 能糊弄过去的。
于是我决定用自己最熟悉的 JS/TS 技术栈把它完整落地:Next.js 做应用壳和 API 层,LangGraph.js 做 Agent 编排引擎。这篇文章就是这次完整落地后的复盘,包括选型逻辑、状态图设计、四大功能模块的细节、并发调优过程、部署上线的坑。适合正在用 JS/TS 做 AI 应用,或者准备把“聊天机器人”升级成“能干活 Agent”的工程师。
1. 选型复盘:为什么技术清单最后只剩 Next.js + LangGraph.js
1.1 我一开始根本不是这个方案
诚实讲,我的第一版是用 FastAPI + LangChain 搭的。原因很现实——AI Agent 圈的教程、范例、踩坑贴,八成以上都是 Python 生态,资料丰富,遇到问题随便搜就能找到答案。LangChain 的 LCEL 表达式、各种 Tool 封装、文档加载器,我只是照着文档拼,第一版就顺利跑通了。
但做到第二版我放弃了,不是因为跑不起来,而是这个项目有个躲不掉的需求:要给用户一个能上传简历、实时看进度、在线改稿的界面。用 FastAPI 写接口,前端还得另起一个 React 项目,中间要处理跨域、WebSocket 推送、两套部署管道。为了一个工具型产品维护两套技术栈,不划算。这是个很实际的成本问题,不是技术洁癖。
第二条路是 Node 生态里手写“伪 Agent”:先用 if/else 判断走哪个 prompt,再靠队列或循环调度多轮调用。这个方案前期推进飞快,但做到中后期非常痛苦。你会不断碰到这些需求:
- 某一轮 LLM 返回了格式错误,要不要自动重试?
- 用户看完某段改写说“不行,回到上一版”,状态怎么回退?
- 整个流程跑到一半服务重启了,进行到哪一步了?
- 想让用户在某个节点手动补充信息再继续,怎么暂停?
这些需求本质上是“流程状态管理”。手写到最后,就是在给自己造一个劣质的、充满 bug 的状态机框架,而且可观测性极差——出了问题只能靠日志瞎猜。
LangGraph.js 解决的就是这个问题。它把“Agent 是图而不是链”这个理念直接做成了框架:节点是普通函数,边决定下一步去哪,共享状态在整张图里流动。该重试、该回退、该等人工输入,都是图结构本身的能力,不用自己维护循环和状态了。至于 Next.js 更是顺理成章:API 路由天然支持流式响应,文件上传、表单交互、打印样式这些前端活儿全包,还能和前端同仓库部署。这套组合最终定下来,只花了一个晚上做技术验证。
1.2 三套方案的差距,我整理成了一张表
| 维度 | FastAPI + LangChain | Node 手写状态机 | Next.js + LangGraph.js |
|---|---|---|---|
| 学习成本 | 中高,需要熟悉 Python 生态 | 低,但后期心智负担高 | 中,前端开发者友好 |
| 流程控制 | 链式为主,循环/分支要绕 | 全凭自己写,容易失控 | 原生支持条件边和循环 |
| 断点恢复 | 需要额外引入持久化方案 | 基本没有,重启丢状态 | checkpointer 原生能力 |
| 人工介入 | 需要自己设计回调机制 | 靠自己设计接口 | interrupt 原生支持 |
| UI 衔接 | 要单独搭前端、处理跨域 | 全手写 | 同仓库天然衔接 |
| 维护成本 | 两套技术栈、两套部署 | 越高越接近重写框架 | 单仓库、单语言、社区活跃 |
选型这件事,我的建议是:先想清楚你这个应用是“一次性问答”还是“多步任务流”。前者用 LangChain 或直接裸调 API 都行,后者值得认真评估 LangGraph。简历优化明显属于后者——多步骤、可回退、需要人机协作。
1.3 链是单向的,图是带环的
我后来跟朋友解释为什么不用 LangChain 时,用了这个比喻:链像流水线传送带,工件从一头进去从另一头出来,走的是直线;图像车间里的工作台,工件可以在不同的工位之间来回流转,哪个环节不合格就送回上一个工位返工。
简历 Agent 的业务流天然是张图:解析完简历要分析岗位需求,分析完要做差距诊断,诊断完要改写,改写完要质量检查——检查不通过,还得回到诊断环节重新来。这种“环”在链式框架里实现起来非常别扭,但在 LangGraph 里只是加一条条件边的事。所以别被“框架”两个字吓到,LangGraph 的抽象层次其实很贴近真实业务流程。
2. 简历 Agent 的骨架设计:把业务规则翻译成状态图
2.1 State:一张贯穿全流程的“白板”
LangGraph 的核心概念是 State(状态)。它本质上就是一个可以被所有节点读写的共享对象。我刚接触时总忍不住按传统后端思维去想——“每个节点之间应该定义清晰的接口、传参、返回值”。但在 LangGraph 里,节点之间不直接传参,而是通过修改 State 通信。这个设计一开始让我很不适应,后来才明白它的好处:任何节点的中间结果都可以随时查看、落库、展示给用户,调试时把 State 打出来看一眼,整个流程走到哪一目了然。
我定义的 State 大概长这样:
interface ResumeAgentState { // 输入 fileRawText: string; // 解析后的原始文本 targetJD: string; // 目标岗位 JD // 中间产物 structuredResume?: ResumeSection[]; // 结构化后的简历分块 jdAnalysis?: JDAnalysis; // JD 硬性/软性条件拆分 diagnosis?: GapDiagnosis[]; // 逐块差距诊断 rewrites?: Record<string, string>; // 重写后的内容 // 控制字段 revisionCount: number; // 当前回退轮次 maxRevisions: number; // 最大回退轮次 pendingUserInput?: string; // 等待用户补充的信息 }这里有一个容易忽略的设计点:把revisionCount和maxRevisions这种控制字段放进 State,而不是放在节点内部变量里。因为图可能被中断、持久化、恢复,节点内部变量会丢,但 State 会被 checkpointer 完整保存下来。所有需要跨步骤保留的计数,都放 State。
2.2 节点与条件边:流程长什么样
我把整个 Agent 编排成 6 个节点,用文字描述大概是这样:
- parse_resume:接收上传的 PDF/Word,抽取文本并结构化。
- analyze_jd:解析目标岗位 JD,拆出硬性条件和软性条件。
- diagnose_gaps:把结构化简历和 JD 条件逐条对照,产出差距清单。
- rewrite_sections:针对差距清单逐块改写简历经历,同时做 STAR 重构。
- quality_check:对改写结果做质量检查,判断是否达标。
- render_output:把最终内容渲染成 Markdown/PDF。
节点的连接关系是关键:parse_resume → analyze_jd → diagnose_gaps → rewrite_sections → quality_check,然后quality_check连了两条条件边——达标走render_output,不达标且revisionCount < maxRevisions则回到diagnose_gaps再来一轮。这是整个图最有价值的一条环。
核心代码骨架长这样:
import { StateGraph, END } from "@langchain/langgraph"; const graph = new StateGraph<ResumeAgentState>() .addNode("parse_resume", parseResumeNode) .addNode("analyze_jd", analyzeJDNode) .addNode("diagnose_gaps", diagnoseGapsNode) .addNode("rewrite_sections", rewriteSectionsNode) .addNode("quality_check", qualityCheckNode) .addNode("render_output", renderOutputNode) .addEdge("parse_resume", "analyze_jd") .addEdge("analyze_jd", "diagnose_gaps") .addEdge("diagnose_gaps", "rewrite_sections") .addEdge("rewrite_sections", "quality_check") .addConditionalEdges("quality_check", (state) => { if (state.revisionCount >= state.maxRevisions) return "render_output"; return "diagnose_gaps"; // 质量不达标,回退重来 }) .addEdge("render_output", END) .compile();我实际开发时把maxRevisions设成了 1,也就是最多回退一轮。原因很朴素:回退是要重新调用 LLM 的,每多一轮就多一笔 token 成本,而且用户等着看结果,不能无限循环。如果第一轮改写质量不行,回退一次基本就能找到问题(多半是诊断环节的输入不够精确),再不行就直接出结果让用户手动改。
2.3 为什么这种设计比“一个大 prompt 串全部”强
很多人写完第一版会想:为什么不把所有步骤塞进一个大 prompt,让 LLM 一次全干完?我试过,效果很差。原因有三个:
第一,上下文膨胀。一份完整简历加一段 JD 加一堆指令,一次塞进去往往三四千 token 起步,LLM 很容易顾此失彼——改了这段忘了那段。
第二,可观测性为零。大 prompt 是个黑盒,用户问你“为什么这段被改掉了”,你完全无法回答。拆成节点后,每个节点的输入输出都是结构化数据,你甚至可以把诊断结果直接展示给用户看,体验完全不同。
第三,无法精细控制。简历里的“工作经历”和“项目经历”的改写策略其实不一样,前者更看重职责描述和成果量化,后者更看重技术难点和解决过程。用一个大 prompt 只能笼统处理,拆成节点后,我可以给不同 section 配置不同的改写指令。
这也是我认为 LangGraph 真正价值的地方:它不是帮你“生成”内容,而是帮你“组织”内容的生产过程。
3. 四大功能模块落地:从“会聊天”到“能干活”的关键细节
3.1 简历解析:PDF 抽文本是第一个坑
上传解析听起来简单,实际做起来第一个坑就藏在 PDF 里。很多简历 PDF 是表格排版或者多栏布局,直接抽出来的文本顺序是乱的——上一行还在“项目经历”,下一行突然跳到“专业技能”,再下一行又回到“教育背景”。如果直接把这种乱序文本丢给 LLM 做结构化,诊断结果几乎必然不准。
我的处理方案分三层:
- 用
pdf-parse抽原始文本,按行切分并保留大致坐标(如果有的话)。 - 用正则和关键词做“章节边界识别”——比如找到“工作经历”“项目经历”“教育背景”这些常见标题,把文本切成块。
- 针对切不好的情况,再让 LLM 做一次归一化:给定原始文本,输出结构化的 JSON,用
zod校验返回格式,不合格就重试一次。
第三层是关键兜底。现实中的简历格式千奇百怪,完全靠规则不可能覆盖所有情况。让 LLM 做“最后一公里”的归一化,比纯规则鲁棒得多。但必须用zod这类库做强校验,不能让 LLM 的幻觉污染下游。解析失败时,我的策略是重试一次,换更详细的指令,还失败就明确告诉用户“这份文件格式太复杂,请手动填写基本信息”,不硬撑。
3.2 岗位匹配诊断:把 JD 拆成可对比的条件
这个节点负责把 JD“翻译”成可对比的条件清单,并和简历逐条对照。我把条件分成两大类:
- 硬性条件:技术栈名称、工作年限、学历、特定框架/工具。这些适合做关键词级对比。
- 软性条件:团队协作、沟通能力、架构设计经验。这些需要 LLM 做语义判断。
输出的诊断结果我设计成一张差距表,每一项包含:JD 里的原始描述、拆出的条件、简历中的对应证据、达标状态(达标/不足/缺失)、改写建议。这张表我直接展示在用户界面上,效果出奇地好——用户能直观看到“哪里不行”,比一句“整体竞争力一般”可信得多。
这里有个实操经验:诊断节点输出的结构化数据,是整个 Agent 里质量要求最高的,我设置了temperature: 0,并让模型严格按照 JSON 格式输出。改写节点则可以适当调高温度,让表达更灵活。同一个 Agent 里不同节点用不同模型和参数,是 LangGraph 这类框架才方便做到的优化。
3.3 优化改写:一个 Section 一个子图
改写是整个流程里最耗 token、也最容易上下文爆炸的环节。最初我把所有工作经历一次性丢给 LLM,让它全部重写。结果输出经常偏离原意,而且某一条写得好,另一条就敷衍。后来我改成“一个 Section 一个子任务”:每个工作经历/项目经历单独走一次改写,上下文只包含该段原文、JD 相关条件、诊断建议。
每次改写的 prompt 骨架大概是:
你是资深技术招聘官。下面是我的一份工作经历原文和目标岗位要求。 请按 STAR 法则重写这段经历,要求: 1. 保留全部事实信息,不得编造数据 2. 补充可量化的描述(占比、规模、性能数字) 3. 突出与目标岗位相关的技能关键词 4. 控制在 150 字以内 原文:... 目标岗位要求:...逐块改写的另一个好处是支持“局部重试”。比如某一段输出格式不对,只需要重试那一段,而不是让整个 Agent 从头跑。这在成本控制和错误恢复上是质的差别。
3.4 渲染导出:Markdown 到 PDF 并没有那么简单
Agent 跑完的产物是 Markdown,但用户要的是 PDF 或 Word。我最终选定的方案是:Markdown 先转成 React 组件结构,用 Tailwind 的打印样式渲染成 HTML,再转 PDF。
这里有两个必须处理的细节。一是“一页限制”问题——简历优化完内容变多了,经常超出一页。我做的不是简单地通知用户“内容超了”,而是在渲染节点里按预设排版模板计算内容长度,自动压缩优先级较低的内容(比如把某些点从两行压成一行),尽量保持一页。二是中文字体问题——服务端缺 CJK 字体是必然的,后面部署章节我会展开讲。总之这个模块看似不起眼,其实是整个产品“专业感”的最直接体现,值得花时间打磨。
4. 并发重构:让一个 40 秒的 Agent 任务扛住 20 路并发
4.1 先定位问题:串行调用,一个请求占 40 秒
第一版上线后,我测了一次完整流程,心里一凉:
| 环节 | 耗时 |
|---|---|
| 文件上传与解析 | 3~5 s(视文件大小) |
| JD 分析与差距诊断 | 8~12 s(2 次 LLM 调用) |
| 分块改写 | 18~30 s(4~6 个 Section 串行) |
| 渲染导出 | 2~3 s |
| 合计 | 31~50 s |
一个普通接口 100ms 就超时了,我这个接口动辄半分钟。在线用户一多,服务器线程池直接被打满。这就是热搜词里“AI Agent 怎么扛并发”的典型问题:Agent 的本质是把多个 LLM 调用串起来,单次请求耗时比普通接口高一个数量级,你不能用传统的“快速响应”思路来设计。
4.2 第一层优化:SSE 流式输出,让请求“活着”
我做的第一个改动就是全面改流式。Next.js 的 Route Handler 原生支持ReadableStream,我把 Agent 的每个节点完成事件实时推给前端。效果有两层:第一层是用户体验——用户能看到“正在解析简历”“正在分析 JD”“正在改写第 2 段经历”,焦虑感大幅下降;第二层是技术价值——serverless 平台的超时机制通常看“是否有持续返回”,流式输出能让长任务不容易被强杀。
前端配合也简单,用fetch读response.body就可以:
// 后端 Route Handler 简化版 export async function POST(req: Request) { const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { for await (const step of runAgent()) { controller.enqueue(encoder.encode(`data: ${JSON.stringify(step)}\n\n`)); } controller.close(); }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", }, }); }4.3 第二层优化:队列 + 状态落库,把“等结果”变成“查结果”
流式解决的是单个请求的体验,但扛不住真正的并发高峰。尤其是当用户量上来之后,大量 Agent 任务同时跑,LLM 供应商的限流会先把你打趴。我的第二层方案是把任务改成“提交后异步执行”的模式:
- 用户提交简历和 JD,立即创建一个任务,返回任务 ID。
- 后台从队列里取任务,逐个执行 Agent 流程。
- 前端轮询或通过 SSE 订阅任务状态。
- 每个节点的中间状态都写入数据库,任务中断后可以从最近检查点恢复。
队列我用的是BullMQ + Redis。任务量大时可以做并发上限控制,比如同时最多跑 10 个 Agent 任务,剩下的排队。这样虽然单个任务变慢了,但系统整体不会被打垮,用户体验是“排队中”而不是“请求失败”。
这里有一个取舍要讲清楚:对于实时交互型的 Agent(用户在线等着结果),流式响应是首选;对于批量处理型的 Agent(比如批量优化一批简历),队列模式更合适。简历工具两者都要——单份优化用流式,批量处理用队列。我实际是把两种模式并存,通过一个mode参数切换。
4.4 第三层优化:限流、重试、模型分流
LLM 供应商的限流是所有 AI 应用躲不开的墙。我的处理策略有三条:
第一,指数退避 + 抖动重试。遇到 429/5xx 错误,不能立即重试,要按2^n秒递增等待,并加上随机抖动,避免所有请求在同一时刻重试造成“惊群效应”。
async function callLLMWithRetry(fn: () => Promise<any>, maxRetries = 3) { for (let i = 0; i < maxRetries; i++) { try { return await fn(); } catch (e: any) { if (e.status !== 429 && e.status >= 500) throw e; const delay = Math.min(2 ** i * 1000, 8000) + Math.random() * 500; await new Promise((r) => setTimeout(r, delay)); } } }第二,同一 Agent 内模型分流。诊断和结构化输出用便宜快速的小模型,改写用更强的大模型。我把不同节点的模型配到 State 里,方便随时切换。实测成本能降一半以上,质量没有明显变化。
第三,结果缓存。简历优化是天然带缓存的场景——同一份简历投同一个岗位,结果可以直接复用。我按文件哈希 + JD 哈希 + 模型版本做缓存 key,命中缓存直接秒出结果。这个优化把并发压力降了一个档次,强烈建议做。
4.5 优化后的实测数字
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 单用户完整流程耗时 | 约 40 s | 约 30 s(流式感知更短) |
| 支持同时在线执行数 | 5 个就卡 | 稳定 20+ 个 |
| 失败率 | 5%(超时/限流) | <1% |
| 每日 token 成本 | 基准 | 降约 50% |
这套组合拳打下来,并发问题才算真正解决。核心思路总结成一句话:能用流式就别用轮询,能用缓存就别重复算,能用队列就别让请求硬扛。
5. 部署上线:那些文档里不会写的坑与解法
5.1 Serverless 执行时限和长任务的根本冲突
我最初图省事把整个应用部署在 serverless 平台上。第一次压测就暴露了问题:免费档位的单次执行时长限制只有 10 秒左右,付费档位也就几十秒,而我的 Agent 单次请求要 30 秒以上。解决路径有两条:
- 如果坚持 serverless,必须把任务改成“边算边推”的模式,保证响应流持续输出,并且所有重活通过流式推给客户端。短任务可以这样糊弄过去,但长任务仍有风险。
- 更稳的方案是把 Agent 执行部分拆成独立的 Node 常驻服务,部署在有固定资源的环境里,Next.js 只负责前端页面和 API 入口,内部转发给 Agent 服务。
我最终选了第二条。在我看来,Agent 这种长耗时、高 CPU/内存开销的任务,和 serverless 的执行模型天然不对付。不要为了“托管省心”硬把一个不适合的场景塞进不适合的平台。
5.2 checkpointer 状态存储:内存模式重启就丢
LangGraph 的 checkpointer 是断点恢复的关键,但默认的内存模式只能在单进程里用。一旦服务重启,所有进行中的 Agent 状态全丢。用户刷新页面发现“我的任务没了”,体验极差。
解决办法是把 checkpointer 换成持久化存储。LangGraph.js 官方支持对接 PostgreSQL 等存储,我在 Postgres 里建了一张表存图状态快照。每次节点执行完写入一次,重启后从最近检查点恢复。这里有个细节:写入频率要控制。每个节点都写没问题,因为 Agent 节点数量有限,但如果你的图有大量细粒度步骤,频繁写库会拖慢整体速度。我的经验是只在“关键节点完成后”做一次持久化,而不是每一步都写。
5.3 token 成本失控:Agent 循环里的“内存泄漏”
上线一周后我看账单,差点没坐住。问题出在回退机制上:每次从diagnose_gaps回到rewrite_sections,State 里的诊断结果还在增长,新的改写请求又把旧的诊断内容带上了。多轮循环后每条消息都带着完整历史,成本呈指数上升。
我的解法参考了 LangGraph 文档里的消息压缩思路:在 State 里加一个“上下文裁剪”逻辑,超过一定轮次后,不再把全部历史传给 LLM,而是只传“最新一轮诊断 + 最新改写结果 + 原始简历”,前面的轮次只保留摘要。这相当于给 Agent 的历史消息做一次“压缩 GC”。同时我给maxRevisions设硬上限,从根上防止无限循环。
其他几个成本控制经验一并分享。
- 所有结构化输出用
temperature: 0,避免无意义的多余输出。 - 长时间运行的 Agent 启用供应商侧的 prompt 缓存。
- 同一份简历的多轮用户操作之间,复用已解析的结构化数据,而不是每次都重新解析。
5.4 最后一道坎:PDF 中文字体乱码
这是最让我意外的一个坑。本地开发跑得好好的 PDF 导出,部署到服务器上后中文全变方块。原因很简单:服务器环境没有安装 CJK 字体,浏览器在渲染 PDF 时找不到中文字形。解决方案是把常用中文字体打包进应用资源目录,渲染时通过 CSS@font-face显式引入。
@font-face { font-family: "NotoSansSC"; src: url("/fonts/NotoSansSC-Regular.otf") format("opentype"); font-display: swap; }但字体文件通常以 MB 计,直接全量打包会让部署包巨大。我最后做了字体子集化——只提取用到的几百个常用汉字,生成精简字体文件,体积直接压到几十 KB。这一步做完,PDF 导出才算真正稳定。
最后分享一点个人体会
这个项目从第一版“大 prompt 串一切”,到最终基于 LangGraph.js 的状态图 Agent,我最大的感悟是:Agent 应用的门槛不在写代码,而在把业务规则翻译成状态图。简历优化的业务规则恰好适合用图表达——有分支、有循环、有人工介入点,硬要用线性流程去套,就会处处别扭。
另一个体会是,框架选型真的不用追新。LangGraph.js 在 JS 社区的生态还没有 Python 那边丰富,但它的核心抽象足够稳,文档也基本覆盖了关键场景。我踩过的坑大多是部署和成本层面的,而非框架本身的。如果你正准备做一个多步骤的 AI 工具,我的建议很直接:先画出业务流程的状态图,再从图里反推节点和边,最后才轮到写代码。图画清楚了,代码只是翻译工作。