
做个能用的AI Agent最花时间的往往不是模型调得怎么样而是工程上那些没人替你踩的坑。这个项目把“简历工具”当试验场用Next.js当外壳LangGraph.js当Agent编排内核做了一整套从简历解析、岗位匹配评估到优化建议生成的完整落地流程。不是那种跑通demo就完事的玩具是能扛住真实请求、能流式吐字、能上线部署的工程方案。适合正在研究AI Agent落地、被并发和状态搞到头大的开发者看完可以直接抄。1. 项目整体设计与架构拆解1.1 为什么用Next.js LangGraph.js而不是Python方案很多人的第一反应是做Agent不上LangChain/LangGraph Python版Python生态确实成熟但简历工具本质是一个Web产品要跟用户交互、要处理文件上传、要流式输出结果。如果前端Next.js、后端另起FastAPI中间隔一层HTTPAgent状态同步、错误透传、鉴权都变成额外成本。直接用Next.js统一前后端API Route里跑LangGraph.js整个链路在一套TypeScript里闭环类型还能共享维护成本低很多。LangGraph.js不是Python版的简单翻译它对流式事件、状态更新、工具调用都做了JS友好的设计跟Next.js的流式响应天然合拍。这个选择的另一个理由是部署心智。Next.js可以整体部署到Serverless平台Agent状态图是纯函数式的不依赖本地进程每個请求独立实例化Graph天然适配无状态并发模型。如果改成Python后端还得单独管服务、配跨域、处理日志链路对一个中型工具来说得不偿失。1.2 简历工具的核心能力拆解这个Agent不是简单地把简历丢给LLM然后返回大段文字。拆开来看它要干四件事解析简历从用户上传的原始文本里提取结构化数据包括基本信息、工作经历、技能列表、教育背景、关键词标签。评估匹配把解析结果和职位描述对比输出匹配分数、核心差距、优势亮点。生成优化建议针对匹配度低的点给出简历修改建议包括措辞、技能补充、经历重写方向。多轮追问用户生成结果后还能继续问“为什么觉得我沟通能力弱”或“帮我换一个更量化的说法”Agent要基于之前的分析结果继续作答。这四个点对应的是真实用户场景不是要一份简历报告而是要让简历能真正通过HR和ATS系统的初筛。因此评估维度里必须包含关键词匹配率、工作年限契合度、技能重合度、结果导向表述等细节。1.3 状态机驱动的Agent架构我把整个Agent设计成一张有向图而不是简单的“Prompt→响应”链式调用。用LangGraph.js的状态管理把每次用户输入打包成一个state对象在节点之间流动。节点分别是parse、match、suggest、respond前三个负责干活最后一个负责把结果整理成用户友好的答复。这么做的好处是可控。任何一个节点都可以单独调试、单独降级。比如解析节点失败了不需要整个流程重跑可以只重试那一步。状态对象天然记录了“已经做到哪一步”对排查问题非常有帮助。脑子一热就用Promise.all并行调多个LLM的时代已经过去了真正生产级的Agent需要这种明确的状态流转。2. 核心细节解析与实操要点2.1 State定义与节点设计别把状态设计成垃圾桶LangGraph.js的状态定义使用Annotation.Root每个字段可以指定reducer。我第一次做的时候每个字段都写(a, b) b结果发现Agent在多次交互后把历史消息全冲掉了。后来明确区分了两类状态一类是最终要输出的业务结果如matchScore、suggestions用覆盖式reducer另一类是需要累积的对话上下文如messages用(a, b) a.concat(b)。节点的设计也踩过坑。一个节点函数只做一件事不要试图在match节点里顺便生成建议。原因很实际LLM返回的结果需要校验如果节点职责模糊校验逻辑会纠缠在一起。简历工具的match节点会专门输出MatchResult结构包含分数、维度评分、差距分析suggest节点基于MatchResult单独运行输出可执行的修改清单这样前端拿数据非常直接。节点函数返回的字段会被合并到State里。需要注意的是LangGraph.js默认状态下节点返回一个partial object如果返回了新对象但没包含旧字段旧字段会被reducer处理。覆盖型字段用(a, b) b没问题但累积型字段必须保留concatenate。2.2 工具调用让Agent真正拥有“手”简历Agent如果只是聊天那就没有落地价值。关键在于让它能调用外部工具。我用model.bindTools()把一组JSON Schema工具绑定给模型模型在推理时决定是否调用以及传什么参数。工具包括parseResumeText、evaluateMatch、fetchJobKeywords等等。工具函数本身可以写纯逻辑也可以内部再调一次LLM。比如parseResumeText内部会调用一个gpt-4o-mini模型要求只输出JSON再用zod校验结构确保返回数据可靠。不要把所有业务逻辑都做成工具。工具越少越好每个工具的意义要明确。我最初做了七个工具模型经常选错或者传了一堆无关参数。精简到三个工具后准确率明显提升。工具设计的原则是“给模型最小且必要的操作面”这与给新人安排工作很像指令越明确执行越靠谱。工具函数的健壮性也要单独考虑。外部工具可能返回空数据、超时、格式错误所以工具内部要做好默认值兜底。比如简历解析工具如果提取不到工作经历就返回一个空数组而不是报错中断整个图。LangGraph.js对工具异常的默认处理是向上抛错这会导致整个Agent流程终止因此我给工具加了try/catch并且尽量返回结构化错误信息让模型自己决定怎么处理。2.3 流式输出与前端交互别让用户干等LLM生成内容耗时长如果等全部生成完再一次性返回用户早跑光了。LangGraph.js支持流式输出通过streamMode可以拿到消息增量或状态变化。我的方案是API Route返回ReadableStream把Agent节点的输出切成小块推给前端。前端用fetch的response.body.getReader()逐段读取再把文本实时渲染到页面上。这里有个容易忽略的点LangGraph.js的stream默认事件粒度是values也就是每个节点跑完后的一次状态快照这并不能体现LLM token级吐字。要拿到token增量得使用streamMode: messages它会输出一个包含消息数据的流再配合langsmith或者其他日志工具可以还原出生成过程。实际代码里我是这样写的const stream await app.stream( { resumeText, jobDescription }, { streamMode: messages } ); for await (const [chunk] of stream) { // chunk 是 AIMessageChunk包含增量内容 if (chunk instanceof AIMessageChunk typeof chunk.content string) { enqueue(chunk.content); } }前端不要用useChat死等整个响应要自己维护一个消息队列边读边更新UI。我踩过的另一个坑是在服务端流式输出的过程中如果客户端断开Node流并不会自动取消LLM调用。需要监听req.signal的abort事件手动调用AbortController去终止模型请求否则接口成本白烧。3. 实操过程与核心环节实现3.1 工程目录与依赖安装项目结构我建议这样组织app/api/agent/route.ts作为Agent的HTTP入口lib/agent/graph.ts放状态图和节点定义lib/agent/tools.ts放工具函数lib/schemas.ts放zod校验模型。这样边界清晰后面接测试和监控都方便。依赖安装比较直接npm install langchain/langgraph langchain/openai zod如果你用的不是OpenAI接口可以换langchain/anthropic或本地模型LangGraph.js的createReactAgent底层的ChatModel是可替换的。我生产环境用的是国产大模型兼容OpenAI协议的接口只需要在初始化时传apiKey和baseUrl其余代码完全不用改。3.2 在Next.js API Route里跑LangGraph AgentNext.js的App Router下API Route其实就是一个POST函数。简历文件可能很大默认bodyParser限制是1MB在route handler里可以直接用req.text()获取原始字符串前端先做文本抽取传给后端服务端不再处理二进制文件。这样避免了multipart解析和serverless函数内存限制的问题。核心代码长这样import { NextResponse } from next/server; import { initializeAgent } from /lib/agent/graph; export async function POST(req: Request) { const { resume, job } await req.json(); if (!resume || !job) { return NextResponse.json({ error: 缺少简历或职位描述 }, { status: 400 }); } const agent initializeAgent(); const result await agent.invoke({ resumeText: resume, jobDescription: job }); return NextResponse.json(result); }initializeAgent每次请求都返回一个新编译的图实例这样不会出现状态串号。虽然会有一点重复build的开销但LangGraph.js的compile做了缓存实际测量下来每个图实例化也就几毫秒比起LLM动辄几秒的时延完全可以忽略。3.3 流式接口与前端消费让结果“跑”起来仅用invoke还不够必须上流式。我把同一个API Route改成返回ReadableStreamexport async function POST(req: Request) { const { resume, job } await req.json(); const agent initializeAgent(); const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { try { const res await agent.stream( { resumeText: resume, jobDescription: job }, { streamMode: messages } ); for await (const [chunk] of res) { if (chunk typeof chunk.content string) { controller.enqueue(encoder.encode(chunk.content)); } } controller.close(); } catch (err) { controller.error(err); } }, }); return new Response(stream, { headers: { Content-Type: text/plain; charsetutf-8 }, }); }前端我用一个简单函数接收流边读边渲染到p标签里不需要复杂的状态库。注意要设置一个合理的超时时间比如30秒超过就提示用户“当前生成较慢可稍后重试”。流式接口如果长时间没有数据很多Serverless平台会主动断连所以需要在模型层调整超时参数比如model.timeout。3.4 并发与部署让Agent“扛得住”“AI Agent怎么扛并发”是绕不开的话题。我的经验是不要在Serverless请求内持有任何全局可变状态。每个请求创建新的Agent实例最终把状态交给前端。同时引入一个简单的队列机制把高耗时任务如完整简历评估投递到Redis队列后端用worker异步消费这样用户不需要盯着HTTP连接等结果。具体做法是API Route先返回一个taskId前端轮询一个结果接口。任务消费者调用同一个Agent图跑完后把结果写入Redis设置过期时间24小时。这样并发瓶颈从“同时跑LLM的数量”转移到了“队列积压数量”可用性高很多。对于轻量级追问比如“这句话怎么改”仍然走流式直连不做队列确保交互感。部署方面我推荐用Next.js的standalone模式构建方便放进容器或用平台默认的Serverless。LangGraph.js不依赖本地服务所以可以放心部署到各种serverless环境。但要注意LLM API的并发配额最好在服务端做一个简单的令牌桶限流防止上游被限流导致大量5xx错误。4. 常见问题与排查技巧实录4.1 问题速查表真实项目里踩过的坑现象根因解决办法Agent输出字段总是丢State字段reducer写错检查Annotation.Root定义累积型字段用concat工具调用返回报错流程中断工具内部未做异常兜底工具里全面try/catch返回结构化错误流式输出时不吐字等了十秒才一次性输出streamMode用错或模型未开流式API Route改用streamMode: messagesNext.js返回413请求体过大默认bodyParser限制前端抽成纯文本服务端改req.text()后自定义限制多请求间状态串了图实例被全局复用每次请求initializeAgent()创建新实例前端断连后LLM仍在烧钱未处理req.signal中断监听abort事件调用abortController.abort()4.2 几个我没有写在README里的经验第一次做流式渲染时我把所有事件都推给前端包括中间节点的工具调用日志。用户看到一堆JSON后直接懵了。后来我只推AIMessageChunk的文本内容工具调用过程全部折叠到后台日志。产品体验和调试信息要分开。另一个经验是给Agent的每个节点加上耗时统计。刚开始觉得这是小事后来发现很多“AI很慢”的体感其实不是模型慢而是某个工具函数在解析大文本时卡了。我加了一句console.time直接在日志里看到每个节点的耗时秒级定位瓶颈。建议所有做Agent的人从第一天就养成给节点计时的习惯。最后关于Prompt。LangGraph.js本身不管Prompt但节点Prompt的质量决定Agent下限。简历评估节点我会在Prompt里塞入具体的评分维度并要求模型返回JSON对象。看起来“不够AI”但对产品来说最稳。如果你也想做类似工具一开始就别追求Agent全自动编排先用状态图把流程卡死再在每个节点里给模型足够的自由度。这样既能保证结果稳定又能让模型发挥最大价值。这个项目做完之后我最大的体会是AI Agent落地难不在模型而在工程细节。状态怎么管理、流怎么推、接口怎么超时、并发怎么扛每一个问题都会真实地找上门。希望这次分享能让你少踩几个坑。