☰
Next.js+LangGraph.js实战:构建简历优化AI Agent的完整指南
2026/10/8 10:57:43 网站建设 项目流程

聊一聊最近完整落地的一个项目:基于 Next.js 和 LangGraph.js 构建的简历工具 AI Agent。这不是一个简单的"简历模板生成器",而是一个有状态、多步骤、带分支决策和循环反馈的智能代理系统。用户上传一份简历、粘贴目标岗位 JD,Agent 会自己去解析简历结构、分析岗位要求、评估匹配度、生成针对性优化建议、逐段重写履历条目,最后产出一份可直接导出的新版简历。整个过程中,页面会实时展示"Agent 当前正在做什么、下一步要做什么",用户能看到每一步的中间结果,而不是干等一个最终回答。

这篇分享适合正在做 AI Agent 类应用、被 LangGraph.js 的状态图设计绕晕过、或者想把大模型能力真正落成可运营产品流程的人。我会把项目最初踩的坑、架构选型的真实理由、LangGraph 状态图的完整设计、Next.js 侧的流式对接方案、以及上线后遇到的性能问题都摊开讲。项目前后跑了三周,中间推翻过一次架构,这些经验全部来自实际操作,不是从文档里抄出来的。

1. 为什么要用 AI Agent 做简历工具,以及整体架构怎么定

1.1 简历工具的核心需求拆解

先说清楚这个产品要解决什么问题。市面上已有的简历工具大多是表单式的:用户手动填写教育经历、工作经历、项目经历,然后套一个模板导出 PDF。这种方式对大多数人来说太痛苦了,因为大部分用户手里只有一份旧简历,他们要的不是"从零填表",而是"帮我改好现在这份"。

更深一层的需求是:简历优化这件事本身是强上下文依赖的。同样一份简历,投技术岗和投产品岗,优化方向完全不同。用户在招聘网站上看到某个岗位,希望知道自己能不能投、差距在哪、简历上哪些条目需要重写。这要求系统同时理解简历文本和目标岗位 JD,并且有能力做逐条对比和改写。

把需求拆开来看,整个流程包含这些环节:

  • 解析上传的 PDF/DOCX/文本简历,提取出教育背景、工作经历、项目经历、技能清单等结构化信息。
  • 分析目标岗位 JD,提取出硬性要求、技能关键词、职责范围、可能期望的工作年限。
  • 对比简历和 JD,计算匹配度评分,定位差距点。
  • 根据差距生成具体的修改建议,比如"第一条工作经历需要补充量化成果""缺少 Docker 相关经验描述"。
  • 按照建议逐段重写简历条目,要求在不夸大事实的前提下突出与目标岗位的关联。
  • 把所有重写过的段落合并,生成完整的优化后简历,并支持导出。

这些环节如果用一次大模型调用硬做,Prompt 会膨胀到无法维护,输出格式也不稳定。更关键的是,用户在实际使用中发现某个环节不满意,比如"匹配度那步算得不对",系统需要能够单独重跑那一段,而不是让用户重新上传一次简历。这种可拆分、可重试、可追踪状态的需求,天然适合用 Agent 工作流来承载。

1.2 技术选型:Next.js 和 LangGraph.js 各自承担什么

先交代一下我为什么选 Next.js。这个项目需要的是"重交互前端 + 服务端 API + AI 流式输出"一体化的方案。Next.js 的 App Router 支持 Server Components,页面首屏渲染很快;API Routes 可以直接写在同一个项目里,不用单独维护后端服务;部署时无论是上 Vercel 还是自建 Node 服务,都很顺手。更重要的是,AI 场景下的流式文本输出,在 Next.js 的 Route Handler 里实现 SSE 推送非常自然,不需要额外搭一个 WebSocket 服务。

LangGraph.js 是这次架构调整的核心。项目最初版本用的是 Chain 式调用,就是线性地"解析 -> 评分 -> 建议 -> 重写"一步步串下来。跑通之后发现问题很大:一旦评分偏低,我想让它"多跑一轮优化建议"就做不到了,因为 Chain 是写死的顺序;而且中间任何一步出错,整个链就要从头再来,浪费大量 token。LangGraph.js 的核心能力正好补上这两个短板:它允许在节点之间画条件边,根据运行时数据决定下一步走哪个节点;也允许画回边,让流程可以循环迭代。它本质上是一个图状态机,状态 State 在节点之间显式传递,每一步的输入输出都透明可追踪。

我还对比过自己手写状态机。手写状态机在只有三四个步骤的时候没什么问题,但简历优化这个场景有六个以上的节点,还要做超时重试、条件路由、会话持久化,手写代码会迅速复杂化。LangGraph.js 把这些都封装好了,尤其是内置的 Checkpoint 机制,可以把每一步的状态存下来,用户中途刷新页面还能接着跑。这个能力在普通后端开发里得自己写一套快照系统,用 LangGraph 就省掉了。

1.3 整体架构:从页面到 LLM 的完整链路

架构上分三层:前端展示层、服务编排层、模型调用层。

前端就是 Next.js 页面,负责两件事:用户上传简历和 JD;展示 Agent 运行过程中的实时状态。页面上有一个主区域显示 Agent 当前执行的节点名称、已生成的中间结果、步骤日志,另一个区域在流程结束后展示最终简历内容。

服务编排层是核心,跑在 Next.js 的 Route Handler 里。收到请求后,先在服务端构建 LangGraph 的 StateGraph,传入初始状态(原始简历文本、JD 文本),然后调用graph.stream()以流式方式执行整个图。每一步节点的输出都会包装成 SSE 事件,推到前端。为什么放在服务端执行而不是浏览器里跑?因为模型 API Key 不能暴露在客户端,而且解析 PDF、调用 OCR 这些操作也必须在服务端完成。

模型调用层就是封装好的 LLM 调用模块。我基于 LangChain.js 的 ChatModel 接口做了统一封装,默认用 GPT-4o-mini 做解析和评估,用 GPT-4o 做重写这种需要更高质量的节点。底层模型可以配置切换,换 Claude 或本地模型只需要改环境变量,因为 LangGraph 节点里只依赖 ChatModel 接口,不绑定具体厂商。

这里有个架构上的取舍值得说:我没有把前后端拆成两个独立项目。简历工具这种场景,页面交互和 Agent 执行有强关联,前端要实时展示 Agent 的事件流,如果拆成两个服务,还得额外处理 CORS、鉴权、事件推送协议对齐。合在一个 Next.js 项目里,Route Handler 直接调用编排层代码,事件经过 ReadableStream 推到前端,链路最短,排查问题也方便。等以后并发量上来了再拆,架构上留好接口就行,不必一开始就微服务化。

2. LangGraph.js 实现简历 Agent 的状态图设计

2.1 State 数据结构:一切中间产物都进 State

LangGraph.js 的整个执行模型都围绕 State 展开。State 是一个可序列化的对象,节点函数接收当前 State,返回部分更新,图的运行时负责把这些更新合并回完整状态。这意味着所有节点之间共享信息只能通过 State,不能靠模块级全局变量,也不能偷偷在节点内部缓存什么。

我最初犯过一个错:在解析节点里把结构化简历保存到了模块级变量,想着后续节点直接读那个变量就行。结果图跑到一半会话恢复时,那个变量已经没了,因为 LangGraph 的 Checkpoint 只保存 State,不保存模块内存。后来乖乖把所有中间产物都放进 State,才解决了恢复问题。

最终 State 的结构设计如下:

interface ParsedResume { personalInfo: { name: string; email: string; phone: string }; education: EducationItem[]; workExperience: WorkItem[]; projects: ProjectItem[]; skills: string[]; } interface GapItem { category: "hardSkill" | "softSkill" | "experience" | "achievement"; description: string; severity: "high" | "medium" | "low"; } interface ResumeState { originalText: string; parsedResume: ParsedResume | null; targetJob: string; analyzedJob: JobRequirement | null; matchScore: number | null; scoreBreakdown: Record<string, number> | null; gaps: GapItem[] | null; suggestions: string[] | null; rewrittenSections: Record<string, string> | null; finalResume: string | null; error: string | null; events: AgentEvent[]; }

设计 State 的时候有两条原则。第一,只放跨节点需要的数据,节点内部使用的临时变量不必入 State,否则 State 会越来越臃肿,Checkpoint 存储成本也会增加。第二,写入 State 的数据尽量是结构化数据,而不是大段原始文本。比如解析节点产出的是ParsedResume对象,而不是把原文再存一遍。这样后续节点每次读取都不需要重新处理文本,节省 token 也减少重复计算。

2.2 节点编排:从解析到成稿的六步流程

整个图一共有六个主要节点,每个节点职责单一,只做一件事。下面这个表格记录了每个节点的输入来源、输出字段和实现方式。

节点主要输入输出字段实现方式
parse_resumeoriginalTextparsedResumePDF 文本抽取 + LLM 结构化解析
analyze_jobtargetJobanalyzedJobLLM 提取岗位要求和关键词
evaluate_matchparsedResume, analyzedJobmatchScore, scoreBreakdown, gaps规则打分 + LLM 语义评分混合
generate_suggestionsparsedResume, analyzedJob, gapssuggestionsLLM 生成逐条修改建议
rewrite_sectionsparsedResume, suggestionsrewrittenSectionsLLM 按 section 分批重写
compose_finalparsedResume, rewrittenSectionsfinalResume模板合并 + LLM 整体润色

节点之间的连接方式如下。parse_resume和analyze_job是两个并行入口,都可以从初始状态直接进入,没有先后依赖,所以我在图里用了两条入口边,让这两个节点可以同时执行。它们都完成之后,汇合到evaluate_match。后面evaluate_match根据评分结果走条件边:分数高于阈值就直接进入generate_suggestions;分数低但还有优化余地,也会进入generate_suggestions,只是 Prompt 会带上更严格的指令。关键的分支在generate_suggestions之后,根据是否已经重写过,决定回到evaluate_match再来一轮,还是进入compose_final。

代码上大概长这样:

const workflow = new StateGraph(ResumeStateSchema) .addNode("parse_resume", parseResumeNode) .addNode("analyze_job", analyzeJobNode) .addNode("evaluate_match", evaluateMatchNode) .addNode("generate_suggestions", generateSuggestionsNode) .addNode("rewrite_sections", rewriteSectionsNode) .addNode("compose_final", composeFinalNode) .addEdge("parse_resume", "evaluate_match") .addEdge("analyze_job", "evaluate_match") .addConditionalEdges("evaluate_match", routeAfterEvaluation) .addConditionalEdges("generate_suggestions", routeAfterSuggestions); const app = workflow.compile({ checkpointer: postgresSaver, recursionLimit: 30, });

2.3 条件路由与循环机制:Agent 真正的决策点

条件路由是 LangGraph.js 比 Chain 式调用强的一个核心点。routeAfterEvaluation这个函数读取 State 里的matchScore和rewriteCount,返回下一步要走的目标节点名称。我用它在两个地方做了分支。

第一个分支在evaluate_match之后。如果匹配度低于 60 分,说明简历和目标岗位差距很大,直接生成建议可能覆盖不全,我会让流程先进入一个隐藏的deep_gap_analyze节点做细粒度差距分析,再回到generate_suggestions。如果分数高于 60,就直接走常规建议路径。

第二个分支在generate_suggestions之后,控制循环次数。简历优化这种场景,一轮改写往往不够。我给 State 加了一个rewriteRound字段,每执行完一轮"评估 -> 建议 -> 重写"就加一。routeAfterSuggestions判断:如果rewriteRound < 2并且matchScore还有提升空间,就返回evaluate_match再跑一轮;否则进入compose_final。

循环必须要有终止条件,否则一旦模型输出异常,图会无限跑下去。我做了双重保险:一是routeAfterSuggestions里限制最多两轮;二是编译时设置recursionLimit: 30,这是整个图执行步骤的上限,相当于一个安全熔断开关。一旦触发recursionLimit,LangGraph.js 会抛出一个专门的错误,我在 API 层捕获后转成友好的提示返回给前端。

这里想多说一句:很多人第一次用 LangGraph.js 会把它当成"更好的 Chain",只画一条直线,完全没用上条件边。其实 Agent 和普通 LLM 工作流的分水岭就在这里——运行时根据实际数据决定路径,而不是写死执行顺序。简历工具这个场景天然适合图编排,因为你无法预知用户的简历质量和岗位匹配情况,必须让系统根据中间结果自己决定下一步做什么。

3. Next.js 侧的实现细节

3.1 API 路由与 SSE 流式响应

Next.js 里实现 SSE 流式响应,我用的 Route Handler 配合 ReadableStream。路由收到前端 POST 请求后,先解析上传文件和 JD 文本,构造初始 State,然后编译并运行 LangGraph。关键点是调用graph.stream()而不是graph.invoke(),因为前者会逐节点返回执行事件,可以用来组装 SSE 消息。

Route Handler 的简化代码如下:

export async function POST(req: Request) { const formData = await req.formData(); const file = formData.get("resume") as File; const targetJob = formData.get("jd") as string; const originalText = await extractTextFromFile(file); const initialState: ResumeState = { originalText, targetJob, // 其余字段初始化为 null }; const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { const run = app.stream(initialState, { recursionLimit: 30 }); for await (const event of run) { const payload = encoder.encode(`event: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`); controller.enqueue(payload); } controller.close(); }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream" }, }); }

这里有一个实际测试中容易忽略的细节:SSE 消息的data字段必须是一行 JSON,不能包含换行,否则浏览器端的解析会错乱。LangGraph 输出的结构化数据里如果含有换行符,发送前一定要先序列化并替换掉。我在生产环境遇到过几次"前端事件丢了一半"的情况,排查到最后都是因为 JSON 里有未转义换行。

3.2 前端状态管理与实时展示

前端消费 SSE 的方式,我选了fetch配合 ReadableStream 手动解析,而不是用 EventSource。原因是 EventSource 只能用 GET 请求,而上传简历文件必须用 POST,EventSource 根本带不上 FormData。用fetch+ReadableStream的方式可以保持 POST 语义,同时逐块读取响应文本。

前端维护 Agent 事件列表的状态很简单,我用useReducer就够了,没有引入 zustand 或 redux。因为事件流本质上是追加型数据,Reducer 的appendaction 能完美覆盖这个场景。界面上有一个步骤时间线组件,每收到一个node_started事件,就在时间线上加一个节点标题;每收到一个node_finished事件,就在对应节点下追加输出摘要。用户能非常直观地看到"Agent 正在跑第几步"。

还有一个交互细节:rewrite_sections节点执行时间比较长,模型要逐段生成重写后的内容。我在前端做了"逐段展示"的效果,每当后端推送一小段文本,页面就局部更新,用户可以看到重写内容像打字机一样出现在屏幕里。这种体验比等一个完整 JSON 再渲染要好太多,用户会觉得系统"真的在思考"。

3.3 会话快照与持久化:刷新也不丢进度

LangGraph.js 的 Checkpoint 机制是我选它的一个重要加分项。在编译图时传入checkpointer,LangGraph 会自动把每一步的 State 存下来,并为每次运行分配一个threadId。用户刷新页面、甚至关闭浏览器再打开,只要还能拿到threadId,就可以从上次中断的地方继续执行。

生产环境里我用了 Postgres 做 Checkpoint 存储,实现了一个PostgresSaver,继承 LangGraph 的BaseCheckpointSaver。核心接口就两个方法:put和getTuple,把序列化后的 State 存进数据库表,按threadId+checkpointId做索引。这个表结构很简单,字段是 thread_id、checkpoint_id、state_json、created_at。

这里有一个重要的架构边界:所有 LangGraph 的执行和 Checkpoint 读写必须放在服务端,客户端永远只能持有threadId字符串。模型 API Key、数据库连接串这些都是服务端机密。Next.js 的 Route Handler 天然在服务端运行,只要注意不要把这些值传到客户端组件就算安全。

4. 核心功能实现:简历解析、评分与职位匹配

4.1 简历解析:PDF、DOCX 到结构化 JSON

简历解析是整条链路的地基,如果这一步产出脏数据,后面所有节点都会受影响。我同时支持 PDF 和 DOCX 两种格式,PDF 用pdf-parse,DOCX 用mammoth,抽取纯文本后交给 LLM 做结构化。

PDF 有个经典的坑:扫描件。用户上传的 PDF 如果是图片扫描版,pdf-parse抽出来的是一堆二进制乱码或者空文本。我做了两层兜底:先检测抽取文本的长度,如果低于 200 个字符,就判定为扫描件,走 OCR 通道把 PDF 转成图片后调用 OCR 服务识别。这个逻辑在解析节点内部做分支,对用户完全透明。

文本抽取完成之后,交给 LLM 用 function calling 输出结构化 JSON。我定义了一个 zod schema 描述ParsedResume结构,传给模型的 tools 参数。这样模型输出的就一定是合法的 JSON,不会出现{不闭合这种问题。schema 片段:

const ParsedResumeSchema = z.object({ personalInfo: z.object({ name: z.string(), email: z.string(), phone: z.string() }), education: z.array(EducationItemSchema), workExperience: z.array(WorkItemSchema), projects: z.array(ProjectItemSchema), skills: z.array(z.string()), });

解析节点里还会做一次"合理性校验",防止模型把工作经历重复塞进项目经历。规则很简单:检查每个 work item 的起止时间和项目时间是否冲突,检查教育经历是否包含学校名称,如果缺字段就标记为needsManualReview,不会直接丢弃。这样做的好处是,即使模型解析出错,后面的评分节点也能通过标记数据理解"这份简历信息不完整",给出针对性的改进建议。

4.2 评分算法:规则打分与 LLM 语义评分的混合方案

匹配度评分是用户最看重的指标,也是我改得最多的地方。最初版本的评分完全靠 LLM,让模型看一眼简历和 JD,直接输出一个 0 到 100 的分数。结果非常不稳定,同一个简历换个措辞问,分数能差 15 分以上。后来我改成混合方案:规则打分保证稳定,LLM 做语义补充。

具体打分逻辑分三步。第一步,从 JD 里提取硬性要求。这是analyze_job节点产出的结构化数据,包含技能关键词列表、学历要求、经验年限、职责描述。第二步,对硬性技能做关键词匹配。我从简历的技能列表和工作经历描述里提取关键词集合,和 JD 的技能关键词做交集计算,得到技能覆盖率。这一步是纯规则计算,结果稳定可解释。第三步,对软性匹配度调用 LLM 打分。技能覆盖率只能反映"要素匹配",但无法判断"表达方式是否加分"。比如简历里写了"负责系统开发",JD 要求"主导过分布式系统架构",关键词不直接匹配,但语义上是相关的,这时候靠 LLM 判断更准。

最终评分是把两部分加权合并:

finalScore = hardSkillScore * 0.6 + semanticScore * 0.4

hardSkillScore是规则计算的技能覆盖率乘以 100,semanticScore是 LLM 返回的 0 到 100 语义匹配度。加权权重不是拍脑袋定的,我取了 50 份测试简历做回归评估,发现 0.6/0.4 的权重和人工评分的相关性最高。这个权重也可以通过配置文件调整,不同行业可能最优权重不同。

scoreBreakdown里记录了每个维度的得分,前端会展示一个雷达图或者柱状图,用户能直观看到"技能匹配 80 分、经验匹配 65 分、成果量化 40 分"。这部分透明度很重要,用户不会因为一个笼统的 72 分就信服,看到具体维度才有优化方向。

4.3 优化建议与重写:结构化输出加逐段处理

优化建议节点读取gaps列表,逐条生成建议。prompt 里明确要求每条建议必须包含三部分:问题位置(哪个 section、哪条经历)、问题原因(为什么和 JD 不匹配)、具体改法(怎么改写、补充什么内容)。输出格式用固定 JSON,方便前端直接渲染成建议卡片。

重写节点是整个流程中最耗 token 的部分,也是最容易出质量问题的地方。我的处理原则是:一次只重写一个 section,不要试图让模型一次性输出整份简历。工作经历这种长文本更要拆成单条经历来处理。为什么?一方面是为了控制上下文长度,单条经历的上下文只有两条 Prompt 那么大,模型不容易"遗忘"重要的原始信息;另一方面是方便用户局部确认,重写完的工作经历逐条展示,用户觉得哪条改得不行可以单独再调,不用整份返工。

重写提示词里有一个非常重要的约束:不许编造事实。我反复强调这一点,因为简历是我的核心信誉问题。LLM 很容易在润色过程中把"参与项目"扩写成"主导项目",或者编造根本不存在的量化指标。我在 prompt 里明确写了一条规则:只要原文没有给出数字,就不许编造数字;如果觉得这条经历缺少量化成果,应该在建议中提醒用户自行补充,而不是自动代写。

5. 常见问题与排查技巧实录

5.1 State 更新冲突:并发节点互相覆盖

LangGraph.js 允许多个节点并行执行。我在第一个版本里让parse_resume和analyze_job并行,结果发现两个节点都向 State 里写入了progress这个字段,最后总是只有一个节点的进度被保留,另一个被覆盖。这个问题排查了很久,因为 LangGraph 的运行时不会报错,它只是把后写入的覆盖先写入的。

解决办法有两个,我两个都用了。最快的办法是给并行节点写不同的 State key,比如parse_progress和analyze_progress,从源头避免冲突。另一个办法是用函数式更新,返回值不是具体数据而是(current: State) => Partial<State>形式的函数,LangGraph 会按顺序应用这些更新而不是直接覆盖。对于需要合并多个来源数据的场景,函数式更新是更优雅的方案。

5.2 SSE 流式中断与超时处理

SSE 流式响应在开发环境跑得好好的,部署到线上就经常中断。排查下来有两个原因:一是 serverless 平台的函数执行时长限制,简历重写这种重任务很容易超时;二是代理层对 SSE 的长连接不友好,连接空闲一段时间后被掐断。

针对超时,我的做法是把 Agent 执行拆成两段:耗时较短的解析和评估接口保持普通 POST,前端等待完整响应;耗时较长的重写流程改走异步任务,提交后接口立即返回taskId,前端轮询任务状态。这样即便 serverless 函数超时,任务也已经在后台跑完,不会丢失进度。如果坚持要实时流式展示,可以把 Next.js 部署到 Node 常驻进程,或者是支持长时间运行的托管平台,不要在默认 serverless 配置里硬扛长任务。

前端也加了断线重连机制。收到 SSE 超时或者网络错误时,不是直接提示失败,而是记录当前已经收到的节点数,重新请求时带上这个游标,后端从游标之后继续推送。配合 Checkpoint 机制,这个恢复流程实现起来不算复杂,但用户体验提升非常明显。

5.3 Token 消耗与上下文裁剪策略

Token 成本是这个项目上线后最头疼的问题。一次完整的简历优化流程,如果全程用 GPT-4o,平均消耗在 3 万 token 左右,成本相当可观。我做了三件事来压成本。

第一,分级用模型。解析和评估这种对创造力要求不高的节点,用便宜的 GPT-4o-mini;只有重写段落这种真正需要高质量文本的节点才用 GPT-4o。实测下来,解析和评估环节换用 mini 后,准确率几乎没下降,但 token 成本降了将近一半。

第二,裁剪传入上下文的粒度。重写单条工作经历时,不需要把整份简历都塞进去。我实现了一个trimContext工具函数,根据当前要重写的 section 类型,只传相关的上下文片段。比如重写工作经历时,只传这条经历原文、JD 中相关的职责要求、以及评分节点给出的针对性建议,其他无关内容一律不传。

第三,JD 分析结果做缓存。同一个 JD 如果被多个用户使用,我按 JD 文本的 hash 缓存analyzedJob结果。很多热门岗位的 JD 是重复出现的,命中缓存后能省掉一个节点的模型调用。这个缓存逻辑放在analyze_job节点里,命中就直接读取,没命中才调用模型。

5.4 模型输出格式不稳定与校验兜底

LLM 输出 JSON 尽管用了 function calling,偶尔还是会出现字段缺失或者类型不对的情况。我在重写节点上遇到过几次rewrittenSections里只有一半的 section,导致最终简历缺段落。

处理思路是接入 zod 校验,校验不通过就进入重试逻辑。每个节点函数的输出先过safeParse,失败时最多重试两次,每次重试稍微调整 prompt 强调格式要求。重试仍然失败的,就把该节点的输出标记为失败,但不阻塞整个流程,最终简历里保留原始段落并注明"此段建议人工复核"。这种"部分成功"策略比整体失败用户体验好得多,用户不会因为一个段落格式错误就丢掉整份产出。

6. 部署与性能调优心得

6.1 部署架构:容器化常驻 vs serverless 之争

部署方案我前前后后折腾了三次,经验教训不少。最初直接部署到 Vercel,因为 Next.js 是它的亲儿子,几行配置就搞定。但很快发现长任务不合适,serverless 函数的时长限制和冷启动放在 AI 场景里非常难受。SSE 流式输出本来应该是持续的,被平台一掐就废掉了。

于是我把架构改成了两层:Next.js 应用部署到 Docker 容器,用 Node 常驻进程跑;同一台机器上跑一个轻量的任务队列(用 Redis + BullMQ),负责承接重写和生成这类耗时的后台任务。前端发起请求后,轻量请求直接由 Next.js 进程响应,耗时任务投递到队列,消费者进程处理完后写回 Redis,前端轮询获取结果。这套架构上线后稳定多了,不再受 serverless 平台限制。

6.2 流式输出与缓存优化

SSE 在常驻 Node 进程下表现很好,但我额外加了两个优化。第一,模型输出用流式 API,而不是等服务端完整生成再返回。第二,静态资源全部走 CDN,简历工具的页面主要是 JS 包和样式,体积不大,但缓存命中率提升后首屏加载明显更快。

模型调用层面还有一个值得做的优化:温度参数调低。简历重写这种任务对确定性要求高,我把重写节点的 temperature 设为 0.2,让输出更稳定;解析节点的 temperature 直接设 0,避免纯提取任务出现随机偏差。如果你是做内容创作类 Agent,温度可以调高,但简历工具追求可预测性,低温度更合适。

6.3 并发任务与成本控制

上线运营之后,用户同时提交简历的情况越来越多。我按threadId做并发控制,同一个用户同一时刻只允许一个 Agent 任务在跑,新的请求直接排队。这个限制主要是成本考量,AI Agent 任务不像普通 Web 请求可以无限并发,token 消耗是实打实的账单。

我给用户做了等额配额:免费用户每天可以跑一次完整流程,付费用户不限次数但限制同时运行的任务数量。配额逻辑挂在任务队列入口,超出部分返回明确的提示文案"你已有任务正在运行,请等待完成或取消"。实测下来这个限制对用户体验影响不大,简历优化本来就不是高频操作,用户看到"Agent 正在思考中"反而觉得系统更专业。

最后再分享一点个人体会。项目上线后我发现,用户真正高频使用的不是"一键生成完整简历"这个最终功能,而是"针对目标岗位逐条优化"的过程本身。他们会反复调整 JD 文本,对比不同岗位的匹配度评分,单独修改某一条工作经历的改写结果。这给我的启发是:AI Agent 工具的价值不在于代替用户做完所有事,而在于把过程拆开、让用户看到每一步逻辑、在关键节点上给人控制权。LangGraph.js 的状态图设计正好契合这个理念,每一步都可追踪、可回放、可单独重跑。这种透明感和可控性,是用户在真实场景里愿意长期使用的核心原因。

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

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

立即咨询