这两年聊AI Agent的人很多,但真正能从立项一路推到线上、还能扛住几个人同时用的项目,其实并不多。我自己最近完整落地了一个实战项目,核心组合是Next.js + LangGraph.js,应用场景选了简历优化:用户上传一份PDF简历,再贴一段目标岗位JD,Agent自动完成解析、匹配分析、逐条优化建议,最后直接产出一版可下载的对比简历。从最初只有十几个用户的内部工具,到后来对外开放压测,中间踩过的坑基本都能写成一本书。这篇就把整个落地的设计思路、技术选型、代码实现和排错经验一次讲透,适合想做Agent练手项目或正在把智能体推向生产的团队。
1. 项目定位与整体设计拆解
1.1 为什么拿“简历优化”当Agent落地场景
我见过很多人练手Agent,一上来就想做“全能助手”,结果对话没边界、输出没格式、评估没标准,项目多半烂尾。选简历工具当切入口,是因为它天然满足Agent练手的四个条件:
- 任务边界清晰:输入是简历和JD,输出是结构化分析和优化建议,不需要Agent去探索互联网。
- 用户痛点强:写简历这件事几乎人人需要,而且大多数人的简历确实存在“没量化、没重点、表述平淡”的问题。
- 多步骤有复杂度:识别→分析→生成建议→产出文档,这个链路足够体现LangGraph的状态流价值,而不只是单轮Prompt。
- 产出可评估:优化前后的简历可以直接对比,好不好用一眼看得出来。
这个定位决定了产品的交互形态:不是打开一个空白的对话框让用户自由聊,而是提供一个带引导的“上传简历 + 贴JD + 出报告”工作台。对话作为补充,核心是结果交付。
1.2 为什么是Next.js + LangGraph.js这套组合
技术选型我纠结过一阵。最早想过用Python FastAPI + LangChain,后来还是定了TypeScript全栈。原因很实际:
- Next.js把前端页面、API路由、Serverless部署一把梭,Hobby计划就能跑,个人项目和早期团队用成本极低。简历工具这种页面不算复杂,但需要文件上传、流式返回、Markdown渲染,Next.js的App Router都能干净承接。
- LangGraph.js是我对比LangChain后最终选的。LangChain适合“链式调用”,但Agent一旦带分支、带人工确认、带回放调错,链式表达就啰嗦了。LangGraph是用状态图(StateGraph)组织Agent,节点和边都是显式的,整个工作流画得出来、看得见、能单步调试。
- TypeScript统一栈:两套代码都用TS,前后端共享类型定义,比如Agent返回的Analysis结构可以直接被前端复用,省掉一整套DTO转换。
我更看重的其实是LangGraph的可控性。很多Agent项目死在不稳定上——模型自由发挥,输出时好时坏。状态图逼着你把“流程”和“模型能力”分开:流程是确定的代码逻辑,模型只负责局部的文本生成。这样系统整体是可控的,出问题也容易定位在哪一步。
1.3 工作流设计:从聊天到简历产出
简历Agent的完整工作流,我按LangGraph的节点和边拆成下面几条关键路线:
start→collectRequirements:先询问用户目标岗位、工作年限、想突出什么,收集必要上下文。collectRequirements→parseResume:上传简历后,解析PDF/文本,抽取基本信息、工作经历、项目经历、技能标签。parseResume→analyzeMatch:把解析结果和JD做匹配,计算关键词覆盖度、技能缺口、经历相关度。analyzeMatch→generateSuggestions:逐条生成优化建议,包括表述改写、STAR补充、量化结果。generateSuggestions→generateResume:按目标JD重排内容优先级,生成一版优化后简历(Markdown/PDF)。- 过程中如果简历信息不足,走
askClarification节点,向用户提问补齐,再回到analyzeMatch。 - 任意节点允许用户“回到上一步修改答案”,通过
interrupt实现提前中止和人工介入。
设计成有向图而不是一段Prompt到底,最大的收益是可观测。我在开发期给每个节点打了日志,能精确看到用户卡在哪一步、哪个节点耗时长、哪一步解析结果为空。如果用户反馈“生成结果不准确”,我能直接回放当时的State,而不是盲猜模型抽风。
2. 核心细节解析与实操要点
2.1 LangGraph.js 核心概念:State、Node、Edge、Checkpointer
LangGraph.js的四个核心概念,用流水线来类比最好懂:
- State:整条流水线上所有传送带的汇总状态面板。所有节点只能读写这个State,节点之间不直接传参。
- Node:流水线上的工位。每个工位接收当前State,完成自己的工作后,把改动写回State。
- Edge:流水线轨道。有普通边(走完必到下一个工位)和条件边(根据State内容决定走哪条轨道)。
- Checkpointer:流水线在每天收工时的存档。有了存档,对话中断/重启后可以回到原来的进度继续。
State定义在LangGraph.js里用的是Annotation.Root,其中每个字段可以配置reducer。比如对话消息数组用合并式reducer,简历文本字段用覆盖式reducer:
import { Annotation, MessagesAnnotation } from "@langchain/langgraph"; export const AgentState = Annotation.Root({ // 消息历史,用内置的MessagesAnnotation负责累积 messages: MessagesAnnotation.spec, // 原始简历文本,新值覆盖旧值 resumeText: Annotation<string>({ reducer: (_, next) => next, }), // 目标JD文本 jdText: Annotation<string>({ reducer: (_, next) => next, }), // 解析出的简历结构化数据 parsedResume: Annotation<ResumeData | null>({ reducer: (_, next) => next, }), // 匹配分析结果 matchAnalysis: Annotation<MatchAnalysis | null>({ reducer: (_, next) => next, }), });节点函数接收State并返回部分更新:
async function parseResume(state: typeof AgentState.State) { const text = state.resumeText; const parser = new ResumeParser(); const data = await parser.parse(text); return { parsedResume: data }; }这里有一个新手容易犯的错:节点里不要依赖上次节点产生的局部变量,一切输入输出都走State。因为LangGraph的节点在并发或回放时可能被重新执行多次,只有State是可靠的数据来源。
2.2 简历解析与JD匹配的实现要点
简历解析是最容易翻车的一步。我用了两层方案:
第一层:文本抽取。上传PDF后先用pdf-parse把内容抽成纯文本,再对扫描件尝试OCR做兜底。纯文本丢失了版式信息,但简历的语义原本就是线性的,标题、经历、技能一般都能按顺序抽出来。
第二层:结构化输出。我不用一长串Prompt引导模型“总结一下简历”,而是用JSON Schema约束输出格式。LangGraph节点里调用模型时,把结构化输出的schema传给模型接口,请求模型严格返回指定结构:
async function parseResume(state: typeof AgentState.State) { const model = getModel().withStructuredOutput(ResumeSchema); const prompt = ` 请从以下简历文本中抽取结构化信息,按给定JSON Schema返回。 文本: ${state.resumeText} `; const res = await model.invoke(prompt); return { parsedResume: res }; }JD匹配分析也同理,模型返回一个结构化的差距清单,字段包括matchedKeywords、missingKeywords、experienceGap、suggestions。字段固定之后,前端渲染就简单了,直接用表格展示“岗位要求/当前简历情况/优化建议”,不需要从自然语言里再猜一遍。
2.3 流式返回与并发背后的现实问题
聊天式Agent一定得流式返回,否则用户等那个转圈会疯掉。我做流式时一开始直接用graph.stream(),拿到的是异步生成器,把它桥接到Next.js API Route的ReadableStream上返回前端。前端用fetch读取并逐段解析,跟常见流式输出体验一致。
但流式也带来了两个坑:
- 拦截器问题:Node在流式过程中还会产生中间的“thinking”输出,如果直接全量发给前端,用户会看到一堆乱七八糟的JSON。需要在前端按事件类型过滤,只渲染
messages和最终结果事件。 - 内存问题:默认的
InMemoryCheckpointSaver把对话历史存在Node进程内存里,自己本地玩没问题,一上生产、一多个实例,就会遇到“用户上一轮对话找不到了”。简历工具涉及隐私和数据安全,更不能把数据丢在进程内存里不管。我后来把Checkpointer换成外置存储(MySQL/Redis),并且给每个会话加TTL,过期自动清理。
3. 完整落地实操
3.1 初始化项目与目录结构
我用的Next.js版本是App Router + TypeScript + Tailwind,创建命令如下:
npx create-next-app@latest resume-agent --typescript --tailwind --app --src-dir核心目录结构长这样:
src/ app/ page.tsx // 主页面:拖拽上传 + 对话面板 api/ agent/route.ts // Agent流式接口 upload/route.ts // 简历上传接口 agent/ state.ts // LangGraph State定义 nodes/ // 各节点实现 graph.ts // 组装StateGraph checkpointer.ts // Checkpointer封装 components/ chat-panel.tsx // 聊天气泡区域 resume-upload.tsx // 上传组件 report-view.tsx // 分析报告渲染先把目录结构固定下来很重要。Agent代码和页面代码分开,后续换模型、换存储、加节点都不需要动前端。
3.2 定义Agent状态与组装工作流
状态定义在2.1节已经给过了,这里直接看工作流组装。LangGraph.js的StateGraph用起来很像画流程图,添加节点、声明边、编译:
import { StateGraph } from "@langchain/langgraph"; import { AgentState } from "./state"; import { collectRequirements, parseResume, analyzeMatch, generateSuggestions, generateResume, askClarification, } from "./nodes"; export function buildAgent(checkpointer) { const graph = new StateGraph(AgentState) .addNode("collectRequirements", collectRequirements) .addNode("parseResume", parseResume) .addNode("analyzeMatch", analyzeMatch) .addNode("generateSuggestions", generateSuggestions) .addNode("generateResume", generateResume) .addNode("askClarification", askClarification) .addEdge("start", "collectRequirements"); // 收集信息后,有简历就去解析,没有就进入澄清循环 graph.addConditionalEdges("collectRequirements", (state) => { return state.resumeText ? "parseResume" : "askClarification"; }); graph.addEdge("parseResume", "analyzeMatch"); graph.addEdge("analyzeMatch", "generateSuggestions"); graph.addEdge("generateSuggestions", "generateResume"); // 澄清后回到匹配分析 graph.addEdge("askClarification", "analyzeMatch"); return graph.compile({ checkpointer }); }组装时有个关键参数:compile({ checkpointer })。没有checkpointer,LangGraph只能单轮执行,多轮对话的历史不会自动带上。简历工具需要用户在不同轮次补充信息,所以我必须给整个图加记忆。
3.3 在Next.js里暴露流式接口
Next.js API Route接收前端请求,把输入写入初始State,然后用graph.stream()驱动Agent执行,并把事件流转成浏览器可读的流。代码如下:
import { NextRequest } from "next/server"; import { buildAgent } from "@/agent/graph"; import { createCheckpointer } from "@/agent/checkpointer"; export const runtime = "nodejs"; // 不要用edge,LLM SDK在edge下兼容性差 export async function POST(req: NextRequest) { const { threadId, resumeText, jdText, message } = await req.json(); const agent = buildAgent(createCheckpointer()); const input = { messages: [{ role: "user", content: message || "开始优化我的简历" }], resumeText, jdText, }; const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { try { for await (const event of await agent.stream(input, { configurable: { thread_id: threadId }, streamMode: "updates", })) { // event的结构类似 { nodeName: { 字段更新 } } const payload = `data: ${JSON.stringify(event)}\n\n`; controller.enqueue(encoder.encode(payload)); } } catch (err) { controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: String(err) })}\n\n`)); } finally { controller.close(); } }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", }, }); }前端就用原生fetch消费这个SSE流。事件流转到浏览器后,按照nodeName决定渲染逻辑:parseResume完成后展示解析摘要,analyzeMatch完成后展示差距表格,generateResume完成后提供一个下载按钮。如果直接用AI SDK的useChat,我需要自己实现一套LangGraphAdapter把event翻译成AI SDK消息格式,工作量差不多,所以我直接手写了。
3.4 前端交互与产出物体验
前端我做了三个关键组件:
- 上传区:支持拖拽PDF,文件走
/api/upload上传到对象存储,返回一个临时URL,同时触发解析节点。加了一行“简历仅用于本次分析,系统会在24小时后自动删除”的提示,这个对简历类隐私产品是必备的。 - 聊天面板:普通消息区 + 结构化报告区。结构化报告用
report-view.tsx渲染,内容包括“岗位匹配度”“技能缺口TOP5”“逐条优化建议”。Chat模型的滚动流写在顶部,报告区固定在底部,避免用户被流式推送打乱阅读节奏。 - 结果下载:优化后的简历用Markdown排版,前端预览后导出PDF,调浏览器的打印接口,按A4样式注入CSS。这一步我没有让Agent直接生成PDF,因为模型生成的PDF排版质量太不可控,前端模板渲染反而稳定得多。
前端还有一个细节:中止按钮。Agent跑错方向的时候用户能立刻停止生成,前端AbortController取消fetch请求,后端在ReadableStream的cancel里触发graph的中断逻辑。这个交互平时不太起眼,真上线后我发现很多用户会点它,因为优化大段简历时生成时间可能要20秒,没人能傻等。
4. 常见问题与排查技巧实录
4.1 我印象最深的五个坑
第一个坑:模型结构化输出不稳定。我用withStructuredOutput约束答案时,偶尔还是拿到缺字段的JSON。后来排查发现是Prompt里给的示例不够,模型喜欢偷懒省略数组元素。解决方法是把strict: true打开,并在校验不通过时让节点自动重试一次。重试不是简单“再问一遍”,而是要把缺什么字段明确写进失败反馈里。
第二个坑:LangGraph Checkpointer报错。本来用内存Checkpointer,上线后用户反馈“聊到一半再发消息,Agent失忆了”。查了半天,发现Next.js Serverless冷启动后实例被销毁,进程内存里的存档全没了。换成持久化的检查点存储后问题消失。这类问题在本地开发永远遇不到,因为本地进程一直活着。
第三个坑:扫描版PDF乱码。有个用户传的简历其实是图片PDF,pdf-parse抽出来的文本几乎为空,解析节点直接吐了垃圾结果。我加了一个前置判断:如果文本长度小于100,主动抛给OCR管线。OCR同样不够稳定,所以我在界面上加了“手动粘贴简历文本”的兜底入口。这个兜底上线后使用率比预期高,说明简历来源五花八门,技术方案必须留手动通道。
第四个坑:流式事件顺序乱。LangGraph的stream()返回的事件顺序不一定按节点完成顺序排,尤其是多个分支并行时,前端如果直接按收到顺序渲染,报告会跳来跳去。我处理的办法是给每个事件带上nodeName,前端维护一个固定节点顺序表,等关键节点完成后再渲染对应区块,中间事件先缓存。
第五个坑:Edge Runtime与LLM SDK冲突。我最初想用Edge函数省成本,结果openai等SDK在Edge环境下部分能力受限,还有Node专属模块(比如PDF解析)根本跑不了。排查了一晚上,最终把API路由runtime锁定为nodejs,上传、解析、Agent流式全部走Node环境,页面静态部分继续走Edge缓存。混跑架构看起来没那么“纯”,但实测稳定很多。
4.2 常见问题速查表
下面这个表是我在项目维护期整理的速查表,遇到问题先对号入座:
| 现象 | 可能原因 | 排查/解决 |
|---|---|---|
| Agent记不住多轮对话 | Checkpointer缺失或用了内存存储 | 检查compile()是否传checkpointer;生产换外置存储 |
| 生成内容全是废话 | 解析阶段文本为空 | 看parseResume日志里的文本长度,加OCR或手动粘贴兜底 |
| 前端收到乱JSON | 流式事件类型没过滤 | 按nodeName过滤,只渲染必要事件 |
| 请求超时 | 模型推理太慢或重试次数过多 | 缩短Prompt、限制输出token、给节点加重试上限 |
| 并发一高就无响应 | 内存Checkpointer或上游API限流 | 外置存储 + 模型请求限流 + 队列削峰 |
| 优化后的简历格式错乱 | 模型直接生成PDF/HTML | 改为模型生成Markdown/JSON,前端模板渲染 |
这张表不一定适应所有项目,但它说明一个道理:排查Agent问题,先看日志定位到具体节点,不要一上来怀疑模型。大部分问题其实出在流程边界,而不是模型能力。
5. AI Agent 压测与并发优化实录
5.1 先搞清楚瓶颈在哪
“Agent怎么扛并发”这个问题,我压测前以为是模型API限流,测完发现还有另外两个瓶颈。
第一次压测用k6模拟20个并发用户同时跑“简历解析+生成建议”完整流程,结果惨不忍睹:平均响应时间36秒,接口大量503。逐段排查后发现,耗时大头确实在LLM生成环节,但真正让服务雪崩的是两件事:
- 内存Checkpointer把所有对话状态堆在进程里,20个用户同时写入时频繁触发GC。
- 每个用户的完整Agent流程要调用3-4次LLM模型,而免费/低配额模型API限得很死,串行排队等超时。
如果只盯着LLM延迟优化,方向就带偏了。真实瓶颈是协作流程效率低下,以及无状态服务的水平扩展问题。
5.2 我采用的优化手段
针对这三个瓶颈,我做了四件事:
第一,把Session存储外置化。Checkpointer从内存版换成Redis适配版,每次读写状态都在同一份外部存储中完成。这样即使后面挂多个实例,用户在哪个实例上续聊都不会失忆。代价是每次节点间切换多了一次存储读写,但换来的是水平扩展能力。
第二,无状态化节点。所有节点函数除了State之外不持有任何局部状态,LLM客户端连接复用。上了这一步后,单实例能同时处理的对话数明显提高,因为不再受事件循环里的长任务拖累。
第三,加了一层简单的队列削峰。在API层加了个内存级令牌桶,每个用户每5秒最多发1次请求。简历优化是长耗时的非实时任务,用户根本不需要高频调用。超出的请求直接返回“上一个结果还在生成中”,前端显示等待状态。这层保护对个人项目尤其重要,能防止一个人刷接口刷爆整个服务的配额。
第四,缓存相同输入。JD文本和简历文本做哈希,只要两者相同就直接返回缓存的报告,不再重复调用模型。这个命中率其实不高,因为我做的是一对一优化,几乎每次输入都不同。但我把“标准JD模板”单独拎出来做了缓存,用户粘贴同一套岗位模板时能秒出结果。
5.3 隐私与安全边界
简历属于个人信息,这块不能含糊。我的做法是:
- 默认不把原始PDF落盘到业务数据库,上传后先转存到临时目录,处理完立即删除。
- 数据库只存脱敏后的结构化字段,比如“5年Java后端经验”可以存,姓名、手机号这类敏感字段不落库。如果一定要做用户系统,至少对敏感字段加密存储。
- 会话数据设置7天TTL,到期自动清理,并在隐私页面写清楚数据保留策略。
在展示“优化建议”时,我还会把涉及真实姓名、联系方式等敏感信息打码,只展示工作内容相关部分。这一步虽然不是技术难题,却是判断一个Agent工具是否“能上生产”的分水岭。
6. 一些实话与后续方向
这个项目从第一行代码到稳定服务几百个真实用户请求,最大的经验不是某个框架多牛,而是“Agent应用要先画清楚状态图,再写代码”。我最初直接上手写节点,写到第三个节点发现流程乱了,才老老实实回头用Graph的方式把整个链路画出来。状态图一旦画清楚,LangGraph.js的代码几乎是照着图誊一遍,开发速度和排错效率都会上一个大台阶。
另外一点体会:生产级Agent必须为“不可靠的模型”留后路。结构化输出校验、重试机制、人工确认节点、手动兜底输入,这些都是和模型能力无关的工程保险。宁可代码里多一些防御逻辑,也不要裸奔依赖模型的随机成功。
接下来我打算继续做的两个方向:一是给Agent加一条“Fetch实际招聘信息”的工具节点,让它能搜索目标公司在招岗位并自动对比,这会真正形成从“优化简历”到“投递指导”的闭环;二是把流式事件链路做得更细,让用户能直观看到Agent当前在“读简历”“分析JD”“改写经历”哪个阶段。这些都会在现有LangGraph状态图上扩展节点和条件边,而不需要推翻架构。对一个AI Agent项目来说,架构的扩展性就是它的生命力。