1. 从"简历生成器"到"简历Agent":这个项目到底在解决什么问题
先说一个我在实际开发中反复观察到的现象:市面上绝大多数简历工具,本质上就是一个"表单填写 + 模板渲染"程序。用户填完几个字段,系统套一个模板,输出一份PDF,结束。这种模式最大的问题在于——简历这件事,根本不是一次性填表能完成的。
一份好的简历,是"对话出来的"。你需要不断追问用户:你做过什么项目?你在里面的角色是什么?你带来了什么可量化的结果?你投的是什么岗位、什么行业?不同岗位对同一段经历的侧重点完全不同。一个做电商运营的人,投运营岗和投产品岗,同一段工作经历应该写出两种完全不同的表述。
这正是我从一开始决定不用传统表单、而是做一个AI Agent来做简历工具的核心原因。用户不是被动的"填表人",而是通过自然语言对话,让Agent逐步引导、收集、诊断、改写、润色,最终产出一份结构化简历。这个Agent需要具备多轮对话能力、状态记忆能力、工具调用能力(比如生成Markdown、导出JSON、调用评分模块),还需要在用户中途改主意、跳着回答、一次说一大堆的时候保持流程不乱。
技术选型上,我用了Next.js作为应用框架,LangGraph.js作为Agent编排引擎。你可能会问:为什么不直接用LangChain.js?或者直接裸调大模型API加上自己写状态机?这个问题我放在下一节仔细说。先给你看一个宏观结论:LangGraph.js解决的是"Agent的流程控制"问题,Next.js解决的是"Agent的部署形态"问题。两者结合起来,你得到的不是一个demo,而是一个能真正上线、被真实用户使用、扛得住并发的完整产品。
这篇文章会完整走一遍从零搭建这个简历工具Agent的过程,包括状态图设计、流式输出、会话持久化、并发处理,以及我在实际开发中踩过的一堆坑。适合已经会写基本的Next.js应用、想往AI Agent方向深入的前端/全栈开发者。
2. 为什么是LangGraph.js:Agent流程控制的底层逻辑
2.1 裸调API和LangChain的局限
如果你只是写一个"一问一答"的聊天机器人,裸调大模型API完全够用。一个循环,把历史消息塞进去,拿到回复返回给前端,完事。但简历工具不是这样。
我这里有一个典型场景:用户说"帮我写一份简历,我做过三年电商运营,主要做活动策划,想投运营岗",然后在下一轮又说"哦对了,我其实还负责过数据分析和商家对接"。你作为开发者,需要让Agent识别出几点:
- 用户想生成完整简历,不是修改简历,所以应该走"完整生成"流程而非"局部优化"流程;
- 用户提到了三段经历:活动策划、数据分析、商家对接,但信息量都不够,需要逐个追问;
- 用户的目标岗位是运营岗,行业是电商。
这种"理解意图 -> 拆解任务 -> 分步执行 -> 多轮追问 -> 组装结果"的流程,用裸循环写会非常痛苦。你需要自己维护状态、自己写分支逻辑、自己处理异常的上下文跳转。第一版可能还能跑,加几个功能后代码就成了一团乱麻。
LangChain.js解决了"调用大模型"的封装问题,提供了Prompt模板、输出解析器、工具调用等便利设施,但它在"流程编排"上不够直观。你仍然需要自己用if-else或者回调来处理多步流程,而且每一步之间的状态传递仍然是手动的。更麻烦的是,LangChain.js的AgentExecutor是一个黑盒式的循环——你很难在某个步骤之间插入人工确认、卡点检查、条件跳到别的分支。
2.2 LangGraph.js的状态图思维
LangGraph.js带来了一个完全不同的心智模型:把Agent的整个执行过程定义为一个状态图(StateGraph)。这个状态图由节点(Node)和边(Edge)组成:
- 节点就是"做某件事"的步骤,比如"意图识别节点"、"信息补齐节点"、"简历生成节点"、"质量评分节点";
- 边定义了节点之间的流转方向;
- 你可以给边加上条件(Conditional Edge),让Agent根据上一步的结果自动决定下一步该走哪里——这本质上就是把一个复杂Agent的流程控制逻辑,从散落的if-else中抽离出来,变成一份可视化的、可维护的图定义。
我用一个比喻来解释:如果你把Agent比作一条流水线,LangChain.js给你的是一把扳手,而LangGraph.js给你的是整个流水线的设计图纸加上控制台。前者解决"某个环节怎么做",后者解决"整条线怎么走、什么时候走到哪、异常了怎么处理"。
简历工具Agent的状态图,是我这整个项目里最核心的部分。我先画一下初步设计,你感受一下这个思路:
// 定义Agent状态的类型 interface ResumeAgentState { // 原始对话消息 messages: BaseMessage[]; // 解析出的用户意图:generate | optimize | analyze | interview_prep intent?: string; // 收集到的简历信息 resumeData?: { basicInfo?: BasicInfo; workExperiences?: WorkExperience[]; education?: Education[]; projects?: Project[]; skills?: string[]; }; // 用户目标岗位/行业 targetRole?: string; targetIndustry?: string; // 当前流程阶段:collecting | generating | reviewing | done stage?: string; // 生成的简历文本 generatedResume?: string; // 评分结果 score?: number; suggestions?: string[]; }这个State就是整个Agent的"共享内存"。任何节点都可以读取和更新它,而LangGraph.js会负责在不同的节点之间传递这个State。你不需要手动把数据从一个函数传给另一个函数——图框架帮你干了这件事。
2.3 为什么和Next.js是绝配
你可能还会问:LangGraph.js是纯Node/TypeScript库,我完全可以单独跑一个服务,然后用Next.js当前端。为什么要把它们放在同一个项目里?
原因有两个。第一,简历工具的核心交互是"流式对话",而Next.js的Route Handlers天然支持ReadableStream响应。Agent的每一个节点执行结果、每一步思考过程,都可以通过Server-Sent Events(SSE)或者Streaming Response直接推送到浏览器。如果你把Agent部署成一个独立的微服务,中间还要过一层协议转换,反而增加了复杂度。
第二,Next.js的Serverless部署模型非常适合Agent类应用。简历工具的用户不是24小时都在用,通常是求职季集中访问。用Serverless按需扩容,冷启动由平台处理,我只需要关注Agent逻辑本身。在下一节我会展示完整项目落地时的目录结构和部署形态。
3. 项目落地的第一步:环境搭建与LangGraph.js基础骨架
3.1 初始化项目结构
我用的Next.js 14(App Router模式)+ TypeScript + pnpm。不推荐用JavaScript写,因为Agent的状态管理要定义很多类型,TypeScript能帮你提前发现状态字段拼写错误、节点返回类型不对这类低级问题。
# 初始化项目 pnpm create next-app@latest resume-agent --typescript --app-router --eslint cd resume-agent # 安装核心依赖 pnpm add langgraph @langchain/openai langchain # 如果你用的是国内模型或Azure OpenAI,可以换成对应的LangChain集成包完整目录结构布局如下:
resume-agent/ ├── app/ │ ├── api/ │ │ └── agent/ │ │ ├── route.ts # 处理对话请求的Route Handler │ │ └── stream.ts # 流式响应的辅助函数 │ ├── page.tsx # 前端聊天界面 │ └── layout.tsx ├── src/ │ ├── agent/ │ │ ├── graph.ts # 简历Agent状态图定义 │ │ ├── nodes/ │ │ │ ├── intent.ts # 意图识别节点 │ │ │ ├── collect.ts # 信息收集节点 │ │ │ ├── generate.ts # 简历生成节点 │ │ │ ├── review.ts # 简历评分节点 │ │ │ └── optimize.ts # 简历优化节点 │ │ ├── state.ts # 状态类型定义 │ │ └── prompts.ts # 所有Prompt模板 │ ├── lib/ │ │ └── model.ts # 大模型实例的统一出口 │ └── types/ │ └── resume.ts # 简历数据类型定义这个结构的关键在于:Agent逻辑和Next.js路由层彻底分离。src/agent下的一切都是纯Node.js代码,不依赖Next.js的API;app/api层只负责接收HTTP请求、调用Agent、把流式结果返回给前端。这样将来想把Agent单独拆出去部署,或者接到Slack、飞书等平台,只需要换一个入口适配器,核心逻辑一行不用动。
3.2 首个可运行的LangGraph流程图
我们用一个最小示例来验证LangGraph.js的流程跑通。在src/agent/graph.ts中定义一张最简单的图——包含两个节点:意图识别和简历生成。
import { StateGraph, END } from "langgraph"; import { ResumeAgentState } from "./state"; // 节点函数:接收整个State,返回State的部分更新 async function intentNode(state: ResumeAgentState): Promise<Partial<ResumeAgentState>> { // 这里调用LLM对用户最近一条消息做意图分类 const intent = await detectIntent(state.messages); return { ...state, intent, stage: intent === "generate" ? "collecting" : "processing" }; } async function generateNode(state: ResumeAgentState): Promise<Partial<ResumeAgentState>> { const resume = await generateResumeFromState(state); return { ...state, generatedResume: resume, stage: "done" }; } // 条件边:根据意图决定下一步走哪个节点 function shouldContinue(state: ResumeAgentState): string { if (state.intent === "generate") return "generate"; if (state.intent === "optimize") return "optimize"; return "collect"; } // 组装图 const builder = new StateGraph<ResumeAgentState>({ channels: { messages: { value: (left?: BaseMessage[], right?: BaseMessage[]) => [...(left ?? []), ...(right ?? [])] }, intent: { value: (left?: string, right?: string) => right ?? left }, resumeData: { value: (left?: any, right?: any) => right ?? left }, generatedResume: { value: (left?: string, right?: string) => right ?? left }, stage: { value: (left?: string, right?: string) => right ?? left } } }); builder.addNode("intent", intentNode); builder.addNode("generate", generateNode); builder.setEntryPoint("intent"); builder.addConditionalEdges("intent", shouldContinue, { generate: "generate", optimize: "optimize", collect: "collect" }); builder.addEdge("generate", END); export const resumeAgentGraph = builder.compile();这段代码的核心逻辑就三件事:
状态定义:
channels定义了State中每个字段如何做更新合并。messages是追加语义,intent是覆盖语义。这个设计很关键——LangGraph.js不是一个简单的State对象,它对每个字段的更新策略都做了明确的声明,避免多节点并发写入时的竞态问题。节点函数:每个节点接收当前完整State,返回部分更新。节点内部可以调用大模型、调用外部API、做任何异步操作。
条件边:
addConditionalEdges让Agent在节点执行完后自动选择下一步。这是LangGraph.js最强大的一点——流程控制不再散落在代码的if-else里,而是集中在图的定义中。
在Next.js的Route Handler中调用这张图:
// app/api/agent/route.ts import { NextRequest } from "next/server"; import { resumeAgentGraph } from "@/src/agent/graph"; export async function POST(req: NextRequest) { const { messages } = await req.json(); const stream = await resumeAgentGraph.stream({ messages, stage: "start" }, { streamMode: "updates" }); // 把图执行过程中的每一步更新通过SSE返回给前端 return new Response( new ReadableStream({ async start(controller) { // 注意:这里用stream而不是普通return,是为了把Agent的中间状态也推给前端 // 让用户能实时看到"正在分析你的经历..." } }) ); }4. 简历Agent的核心业务流:从意图识别到简历成品
现在进入正题。简历工具Agent和其它领域的Agent有个很大的不同:它既要保证对话的灵活性,又要保证最终产出的结构化数据是完整、可用的。如果用户聊了半天,最后生成的简历缺了教育经历、技能描述空泛,这产品就废了。所以我把整个Agent流程分成了五个阶段,每个阶段对应图中的一个或几个节点。
4.1 意图识别节点:这一步决定了整个对话方向
用户的第一条消息往往信息量巨大。比如:"你好,我工作了四年,做过前端开发也做过一点产品,现在想跳槽去大厂做前端,能帮我优化一下简历吗?"
这句话里包含的信息有:工作年限(四年)、经历(前端开发、产品)、诉求(优化简历)、目标(大厂前端)。意图识别节点需要从这条消息中榨取出所有关键信息,并判断该走哪条流程。我用的是"结构化抽取+意图分类"双管齐下的做法:
const intentPrompt = `你是简历助手的意图识别器。用户消息如下: {userMessage} 请从以下三个维度分析并返回JSON: 1. intent: 用户的核心诉求,只能是以下值之一: - generate(从零生成新简历) - optimize(基于已有简历优化) - analyze(仅做简历分析诊断) - other(闲聊或其它) 2. extractedInfo: 从消息中提取到的结构化信息对象 3. missingFields: 根据intent,列出生成完整简历还缺少的关键字段 只返回JSON,不要多余的说明。`; const intentChain = ChatPromptTemplate.fromTemplate(intentPrompt) .pipe(model) .pipe(new JsonOutputParser());这个节点的输出直接决定了后续流程。我在这里踩过一个坑:一开始意图类型只有generate和optimize,没有analyze。结果用户说"帮我看看这份简历哪里有问题",Agent会把"看看"误判为优化,直接动手修改而不是先做诊断。后来我把analyze独立出来,并且让意图识别节点把抽取到的结构化信息直接写入State,后续节点就能直接使用了。
4.2 信息收集节点:动态追问与信息补齐
意图识别完成后,Agent大概率发现状态里还缺一些关键字段。比如用户要做完整简历生成,但没提教育经历、没提技能清单、没提项目经历。这时候就进入信息收集节点。
这个节点的Prompt设计很讲究。不能机械地列出一个问题清单问用户,那样就是表单了。要让Agent根据已有的信息,生成自然、有针对性的追问。比如用户说了"三年电商运营经验",追问应该是:"你提到做过三年电商运营,那这段经历里你最想突出的是哪方面?是活动策划的执行细节,还是数据分析带来的销量提升?另外,我看你还没有填写教育经历——方便告诉我学校和专业吗?"
这里的关键技巧是一次只追问最关键的1-2个信息,而不是把五个问题一次性抛出去。真实的对话中,用户一次回答两三个问题已经是极限,五个问题只会让用户敷衍了事。
收集节点的Prompt模板:
你是简历信息收集助手。当前已掌握的简历信息如下: {resumeData} 用户的最近一条消息是: {lastMessage} 请判断: 1. 如果简历信息已基本完整(基本信息、工作经历、教育经历、技能都齐全),回复"信息收集完毕" 2. 否则,根据缺失字段,用自然的对话语气向用户追问,最多追问2个问题,且必须有明确的优先级 注意:不要机械地说"请问您的工作经历是?"这类问题,要结合用户已有的回答,用"你刚才提到..."的句式自然衔接。当收集节点判断信息已完整时,通过条件边跳转到简历生成节点;否则继续停留在收集节点。这个机制让"收集"和"生成"两个阶段形成闭环——用户随时可能补充新的信息,图会重新回到收集节点做判断。
4.3 简历生成节点:结构化输出的核心
简历生成是整个Agent里技术含量最高的节点,还牵扯到评分。输入是State中的resumeData结构化数据,输出是一份符合用户目标岗位定位的简历文本。
实现要点有三个:
第一,基于岗位定向调整内容权重。同样是做电商运营,投"天猫运营主管"和投"新消费品牌增长负责人",简历上对同一段经历的表述几乎应该是两份不同的内容。所以这个节点的Prompt必须包含targetRole和targetIndustry,并要求LLM对每段经历做重新裁剪和排序。
第二,强调量化结果。这是一份简历能否通过初筛的关键。所以Prompt里我定义了一个硬性规则:每段工作经历至少包含一个数字量化结果,如果没有,先基于原文合理推算,并标注让用户确认。这个规则配合后面的Review节点做双保险。
第三,一次性输出完整的Markdown结构。我要求模型直接输出完整的简历Markdown,包括个人信息、个人总结、工作经历、项目经历、技能清单、教育背景等区块。前端拿到后直接渲染成可预览的界面。
你是一位资深的简历写作专家。请基于以下结构化信息,生成一份面向"{targetRole}"岗位的完整简历。 【结构化信息】 {resumeData} 【生成要求】 1. 使用Markdown格式,使用二级标题区分区块 2. 每段工作/项目经历需包含:背景、你的具体职责、量化成果 3. 量化成果如果没有原始数据,基于合理推算补全,并用【】标出需要用户确认的数字 4. 个人总结控制在3-5句话,突出与目标岗位匹配的核心能力 5. 语言简洁有力,避免"负责""参与"这类弱动词,改用"主导""搭建""优化"等强动词4.4 质量评分节点:让Agent自己给自己的工作打分
在简历生成之后,我加了一个"质量评分节点"。这个节点会以HR和岗位JD的双重视角,对生成的简历进行审查和打分,然后输出结构化的修改建议。
这个节点的价值在于:让用户感知到Agent不是一次性交差了事,而是有质量闭环的。即使之前生成的简历已经不错,评分节点也会给出几条具体的优化建议,用户可以选择"没问题,就这版"或者"按建议改一下"。这种交互方式大大提升了用户对工具的信任感。
const reviewPrompt = `请以资深HR和一线业务主管的双重视角,审查以下简历: {generatedResume} 目标岗位:{targetRole} 从以下维度打分(每项1-10分)并给出文字建议: 1. 匹配度:经历与目标岗位的契合程度 2. 量化度:是否包含足够的具体数字和成果 3. 语言质量:动词选择、句式简洁度 4. 结构完整度:如缺少关键区块请指出 输出JSON格式:{ scores: {...}, suggestions: [...], missingBlocks: [...] }`; // 评分节点会将结果写入State,同时决定是否进入"优化"分支 function routeAfterReview(state: ResumeAgentState): string { const avgScore = calculateAverage(state.score); if (avgScore < 6.5) return "optimize"; // 低于阈值,自动进入优化节点 return "present"; // 达标,直接呈现给用户 }这里做了一个自动分流的设计:如果评分低于6.5分,Agent会自动进入优化节点进行改写,不走"先去问用户"这个环节——因为低分简历直接给用户看,体验很差;但如果评分在6.5分以上,Agent会把简历和评分建议一起呈现给用户,由用户决定是否需要进一步优化。这种"自动处理低质量结果、人工确认高质量结果"的思路在Agent产品设计中很实用。
5. 状态管理与长会话:从单轮到多轮的实现细节
5.1 一次请求和一轮对话的关系
刚开始我犯过一个认知错误:把LangGraph.js图的执行限制在"一次API请求"内。用户每发一条消息就调用一次图,图执行完后整个State就丢了。这导致用户第一轮说了"我要生成简历",第二轮补充了一段项目经历,Agent完全不记得,又从头问起。
正确的做法是:把每一次用户消息当作图的一次"输入",但图的State是跨请求持续的。第一次请求结束时,把最终State持久化保存;第二次用户发消息时,把保存的State作为初始状态传入,让图接着往下走。
LangGraph.js提供了Checkpointer机制专门解决这个问题。它可以在每次节点执行后自动把State保存到存储后端,并且支持用thread_id来关联同一个会话的不同轮次。
import { MemorySaver } from "langgraph/checkpoint"; // 使用内存型checkpointer做开发调试 const checkpointer = new MemorySaver(); const graph = builder.compile({ checkpointer }); // 每次调用时携带thread_id const stream = await graph.stream( { messages: newUserMessage }, { configurable: { thread_id: "user-session-123" }, streamMode: "updates" } );生产环境里,我用的是Redis做checkpointer的后端存储。还用过Upstash Redis,因为它是Serverless友好的,不会像传统Redis客户端那样占用长连接。LangGraph.js官方也提供了Postgres版的checkpointer,如果你团队本来就有Postgres,这可能是最省事的选择。
5.2 节点级别的State合并陷阱
在定义channels的时候有一个非常容易踩的坑:如果节点A返回的Partial State里包含整个resumeData对象,而节点B同时也在更新resumeData的某个子字段,这两个更新在合并时会发生冲突。
LangGraph.js默认的合并策略是"后者覆盖前者"。对于复杂嵌套对象,这个策略可能造成数据丢失。我实际遇到的问题是这样的:
- 节点A(信息收集)从用户消息中提取到新的工作经历,返回
{ resumeData: { workExperiences: [...new] } } - 节点B(意图识别)在核对了用户历史消息后,发现了一条漏掉的技能信息,返回
{ resumeData: { skills: [...new] } }
如果这两个节点没有先后依赖关系,LangGraph.js是支持并行执行节点的。但并行会导致合并时一方覆盖另一方。我的解决办法是:把嵌套字段摊平到State顶层。即不把整个resumeData作为一个channel,而是拆成workExperiences、skills、education等独立的channel。这样每个channel有自己的合并策略,互不干扰。
const builder = new StateGraph<ResumeAgentState>({ channels: { messages: { value: mergeMessages }, workExperiences: { value: mergeArrays }, // 自定义数组合并 skills: { value: mergeArrays }, education: { value: mergeArrays }, targetRole: { value: overwrite }, intent: { value: overwrite } } });这个设计虽然让State的类型声明变多了,但规避了一整类并发更新的bug。如果你的Agent流程里有很多并行节点,建议从一开始就摊平状态结构,不要图省事搞嵌套对象。
5.3 流式输出的完整实现
简历Agent的体验关键点在于:每一步的执行结果都要让用户实时看到。LangGraph.js的stream()方法支持streamMode: "updates",会在每个节点执行完成后产出一个事件。这些事件经过Next.js的Route Handler,通过SSE推送到浏览器。
注意,这里出现了一个技术细节——Next.js Route Handler默认会把Response缓冲起来,等到整个响应结束后才发送给浏览器,那样流式就白做了。你需要通过ReadableStream手动控制发送时机,并且在调用model.stream()的时候也要确保LLM本身是流式返回的。我用的是一个工具函数封装这个过程:
export async function createAgentStream(messages: BaseMessage[]) { const encoder = new TextEncoder(); return new ReadableStream({ async start(controller) { // 状态流:把Agent的流程阶段推送过去 const stream = await resumeAgentGraph.stream( { messages }, { streamMode: "updates", configurable: { thread_id: "..." } } ); for await (const event of stream) { // 事件格式:{ 节点名: 该节点的返回结果 } const payload = JSON.stringify(event); controller.enqueue(encoder.encode(`data: ${payload}\n\n`)); } controller.close(); } }); }前端用EventSource或者fetch配合ReadableStream来消费。我推荐后者,因为SSE的EventSource不支持POST请求,而聊天场景通常需要POST发送消息。前端核心代码:
const response = await fetch("/api/agent", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages }) }); 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); // 解析SSE格式的data行,更新UI状态 const lines = chunk.split("\n").filter(l => l.startsWith("data: ")); for (const line of lines) { const event = JSON.parse(line.slice(6)); updateUI(event); } }6. 并发与性能:AI Agent怎么扛住真实访问量
6.1 冷启动与超时问题
从热搜词"ai agent怎么扛并发"就能看出,这是所有Agent类项目绕不开的痛点。我总结的三板斧是:缩短冷启动路径、任务拆分降耗时、响应模式选流式。
先看超时。传统HTTP接口给你个3-5秒超时是合理的,但Agent调用大模型一次就可能要十几秒,整个图跑下来可能到一两分钟。如果你的Route Handler是跑在Serverless平台上,平台默认的超时通常只有10-30秒,这不够用。解决办法有两个方向:
- 选支持长超时的平台(比如Vercel的Fluid Compute、Cloudflare的延长执行时间);
- 把Agent的执行改成"异步任务+轮询/Webhook"模式:客户端发起请求后立即返回一个taskId,Agent在后台跑完后,前端通过轮询或Webhook拿到结果。
我推荐后端用流式响应、前端用取消式fetch的组合。流式响应可以让整条链路保持长连接,客户端的体验是实时的;同时要做好前端异常处理和重连机制,Web Worker出现断流时自动重新建立连接。
6.2 缓存策略:让重复请求不再重复计算
简历场景有一个其他Agent场景少有的好处——用户的输入有很强的复用性。同一个用户会反复调整同一份简历,周围的字段(教育经历、基本信息)几乎不变,只有某一段工作经历在改。所以我在信息收集节点做了缓存设计:
const cacheKey = `resume:${userId}:${projectId}:${targetRole}`; const cached = await redis.get(cacheKey); if (cached) { // 把缓存的结构化数据注入State,跳过部分收集流程 return { resumeData: JSON.parse(cached), stage: "skip_collect" }; }这一段代码一旦生效,用户的第二次、第三次简历生成就快了很多。因为结构化数据已经就绪,Agent可以直接进入生成节点,省掉了好几轮LLM调用。
另一个缓存点是意图识别。如果用户反复发送相同模版的消息(比如上传了同一份简历),意图识别节点会重复调用模型。我给意图识别加了一层简单的语义缓存:对用户消息做一个向量化,和最近10条请求做相似度比对,命中就直接复用意图结果,不调LLM。这在高并发场景下省了不少token和时间。
6.3 模型层的限流与重试
并发上来之后,大模型API的限流(Rate Limit)是逃不掉的。我在src/lib/model.ts里做了统一的调用封装,内置了指数退避重试和并发限制。
import { ChatOpenAI } from "@langchain/openai"; import Bottleneck from "bottleneck"; const limiter = new Bottleneck({ maxConcurrent: 5, // 同时最多5个LLM请求 minTime: 200 // 每个请求至少间隔200ms }); const baseModel = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0.7, streaming: true }); export const model = limiter.wrap(baseModel.bind.bind(baseModel)) as typeof baseModel;这里有一个关键点:流式调用必须绕过Bottleneck的异步节流。如果limiter包在外层,整个流都会被缓冲,SSE就失效了。我最后是分开处理的:普通推理走Bottleneck限流,流式推理单独走一个只做错误重试的wrapper。这也是实打实的教训,分享出来免得你重复踩。
6.4 并发压测的真实数据
项目上线前我用k6做了一轮并发测试。模拟场景是200个虚拟用户同时发起对话请求,每个用户连续交互5轮。回头看结果,几个关键数字你可以做个参考:
- 纯计算/转发的空转场景(不真正调LLM),Next.js Serverless可以轻松撑住200并发;
- 真实调用LLM时,单个Region的qps基本被模型供应商的限额卡住,瓶颈不在应用服务器;
- 加缓存后,意图识别和基本信息收集的请求有约40%命中缓存,实际LLM调用量下降明显。
这个结果印证了我的判断:Agent应用扛并发的关键不是让应用服务器处理更多请求,而是让应用服务器少调LLM。缓存、状态复用、任务合并,才是Agent并发的核心优化方向。
7. 踩坑实录:LangGraph.js + Next.js集成中的典型问题
7.1 问题一:流式响应在前端被截断
我遇到的第一个诡异问题是:简历生成到一半,前端收到的数据突然断了,没有任何报错。排查链路如下:
- 先看后端日志,Agent图上所有节点都执行完了,没有异常;
- 再看网络面板,发现响应确实结束了,但Content-Length比预期的小;
- 怀疑是代理层的问题——我在本地开发环境一切正常,但部署到生产环境就出问题。
根因是:生产环境的托管平台在响应体超过一定大小或超过一段时间后,会强制断开连接。简历生成节点的输出是完整Markdown简历,加上评分建议,文本量不小。再加上SSE还需要保持长连接,正好踩中了平台的限制。
解决办法是用Next.js的App Router Route Handler配合runtime: "nodejs",并且把发给前端的流式数据做了压缩——不是算法压缩,而是把事件结构精简,只发关键字段,把完整数据放在最后一条结束事件里。
export const runtime = "nodejs"; export const dynamic = "force-dynamic";特别提醒:如果用了runtime: "edge",很多Node.js原生的stream方法在边缘环境的表现和本地完全不一样,会出现难以排查的诡异行为。我的建议是,Agent类应用优先用Node.js运行时,除非你的边缘运行时完全兼容你要用的依赖库。
7.2 问题二:图的状态在长时间对话后膨胀
简历工具还有一类用户场景是"今天了一半,明天接着做"。如果用户在会话里聊了一天,messages这个channel会把所有历史消息都存下来。随着对话轮次增加,每次调用图时都要把全部历史消息传给LLM,token消耗跟着涨,响应也越来越慢。
我在图里加了一个"消息压缩"节点:当messages超过一定长度时,触发压缩节点,用LLM把早期消息总结成摘要。这其实借鉴了LangChain.js里ConversationSummaryMemory的思路,但LangGraph.js的图结构让这个压缩动作发生在固定的节点位置,流程更可控。
async function compressNode(state: ResumeAgentState): Promise<Partial<ResumeAgentState>> { if (state.messages.length <= 20) { return { needCompress: false }; } const summary = await compressMessages(state.messages.slice(0, -10)); return { messages: [new SystemMessage(`对话摘要:${summary}`), ...state.messages.slice(-10)], needCompress: false }; }一个实用的注意事项:压缩时机要在消息不太长的时候就做,不要等到撑到极限再处理。我的阈值是20条,超过就执行压缩。
7.3 问题三:Node函数的隐式依赖让调试变得困难
LangGraph.js的一个特性是节点函数接收完整的State对象。这很方便,但也隐藏了一个问题:节点内部究竟依赖了哪些字段,函数签名完全看不出来。我遇到过的情况是:在修改了某个channel的更新逻辑后,一个看起来无关的节点开始出现undefined错误,排查了很久才发现它内部读取了那个channel的字段。
后来我强制自己在每个节点函数的开头,显式声明需要从State中取出的字段:
async function generateNode(state: ResumeAgentState): Promise<Partial<ResumeAgentState>> { const { resumeData, targetRole, targetIndustry } = state; // 其他字段虽然也在state里,但这个方法不应该读取,这是代码审查的约定 const result = await generateResume({ resumeData, targetRole, targetIndustry }); return { generatedResume: result, stage: "reviewing" }; }这只是代码规范层面的事,但对长期维护帮助很大。LangGraph.js不会强制你这么做,如果你的团队要维护一个复杂的Agent项目,我强烈建议建立这样的约定,否则两周后回头看自己写的代码会一头雾水。
7.4 问题四:生产环境的敏感信息管理
简历包含了用户的联系方式、工作经历等隐私信息,Agent调用LLM的过程等于把这些信息发给第三方大模型。上线前我专门做了处理:
- 所有包含大模型调用的节点,输出内容进入日志前做脱敏(手机号、邮箱、公司名用占位符替换);
- 生产环境禁用
DEBUG模式的LangGraph.js日志输出,避免把完整State打到日志里; - 前端引导用户知悉"AI生成简历会用于模型处理",同时给用户一个"数据删除"的接口。
做AI应用,越早考虑数据边界越好。简历工具的数据敏感级别很高,安全合规是不能回避的底线。
8. 跑通之后的进阶方向:把简历Agent做成一家"产品"
最后聊一下我做完这个项目后的真实体会。Agent类的产品,demo和上线之间隔着一整条"工程化"的鸿沟。图、节点、大模型调用只是最基础的地基,真正决定产品能否存活的是下面的工程细节:
可观测性是Agent项目的地基。每个节点执行了多久、调用了多少次LLM、消耗了多少token、用户的哪些操作导致了流程分支变化——这些数据必须有完整记录。我用LangSmith做了LangGraph.js的官方可观测性接入,它能看到图的每一次节点追踪,定位问题方便很多。自己造轮子做日志系统,复杂度远超你的想象。
Prompt的版本管理很重要。简历生成的Prompt改一个词,可能直接影响整份简历的质量。我把所有Prompt模板放到
src/agent/prompts.ts一个文件里,配合Git做版本管理。每次调整后,会记录测试用例的对比输出,确保改动不会让已有场景退化。要做好数据资产的积累。运行一段时间后,你手里会有大量"用户输入 -> Agent输出 -> 用户反馈"的数据。这些数据是持续优化Agent的最宝贵素材。后面我打算基于这些数据微调一个更适合简历场景的小模型,把推理成本降下来,同时让输出风格更稳定。
这个简历工具Agent做完后,我最大的感受是:LangGraph.js真正改变了Agent的开发方式。以前做Agent最怕的是"流程一复杂就失控",现在整个流程可以从图上直接看出来,每个节点都能单独测试,出问题能找到精确的位置。不夸张地说,这已经是Agent工程化的分水岭——从"调模型写Prompt"升级到"设计状态图编排业务流"。希望这篇文章能帮你少走一些弯路,如果你的Agent项目也在落地阶段遇到问题,欢迎一起交流踩坑经验。