news 2026/10/8 4:50:26

Next.js + LangGraph.js 实战:构建多步骤有状态简历优化 AI Agent

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Next.js + LangGraph.js 实战:构建多步骤有状态简历优化 AI Agent

简历工具这个赛道,看起来简单,实际上坑特别多。我前后做过三版简历相关的 AI 应用,第一版用纯 Prompt 调大模型 API,第二版上了 RAG 做岗位匹配,到第三版才真正把 Next.js + LangGraph.js 这套组合跑通。前两版的问题很典型:单轮对话撑不起"改简历"这种多步骤任务,用户说一句"帮我优化一下",模型要么瞎改一通,要么改完就忘了前面聊过什么。LangGraph.js 解决的就是这个"多步骤、有状态、可回退"的问题,而 Next.js 负责把整个交互体验和流式输出做顺。

这篇内容我打算把整个落地过程拆开讲,包括为什么选 LangGraph.js 而不是直接写个 while 循环、简历解析和岗位匹配这两个核心节点怎么设计、状态图怎么画、流式输出怎么接、部署时踩了哪些坑。适合已经会 Next.js、想上手 AI Agent 但不知道从哪下手的开发者,也适合做过单轮 Prompt 应用、想升级到多步骤 Agent 的同学。全文基于我实际跑通的版本,代码和配置都能直接抄。

1. 为什么简历工具非得上 Agent 架构

1.1 单轮 Prompt 在简历场景的三个死穴

先说清楚问题,不然容易为了用框架而用框架。简历工具的核心任务不是"生成一段文字",而是"理解一份简历 + 理解一个岗位 + 做出一系列修改决策 + 保持前后一致"。这三件事叠在一起,单轮 Prompt 直接崩。

第一个死穴是多步骤依赖。用户上传简历后,典型流程是:解析简历结构 → 提取关键信息 → 分析岗位 JD → 找出匹配缺口 → 生成修改建议 → 逐条应用修改 → 校验修改后是否还通顺。这里面每一步都依赖上一步的输出,而且中间可能需要用户确认。你用一次 API 调用把这些全塞进去,模型会偷懒,经常跳过分析直接给建议,建议还很泛。

第二个死穴是状态保持。用户改到一半说"刚才那个项目经历再突出一下数据",单轮调用根本不知道"刚才那个"指的是哪一段。你得把整个对话历史 + 简历当前状态 + 已应用的修改全部塞进 context,token 消耗爆炸不说,模型还容易抓错重点。

第三个死穴是可回退和可干预。简历修改是个主观性很强的活,用户经常说"这条建议我不采纳,换一个方向"。单轮模式下你只能重新生成,之前的工作全丢。Agent 架构下每个节点是一个独立步骤,可以单独重跑某个节点,前面的结果保留。

我实测过一个对比:同一份简历 + 同一个岗位 JD,单轮 Prompt 方案平均要 4-5 次重试才能得到用户满意的结果,Agent 方案基本 1-2 次就能收敛。差距就在"分步骤 + 有状态"上。

1.2 LangGraph.js 相比手写状态机的实际优势

有人会问,我自己写个状态机 + 一堆 if-else 不就行了,为什么要引入 LangGraph.js?我一开始也是这么想的,第二版就是手写的,写到后面发现几个问题。

手写状态机最麻烦的是条件分支和循环。简历修改流程里有个典型循环:生成建议 → 用户反馈 → 如果用户不满意就重新生成 → 满意就应用。这个循环用 if-else 写出来是一坨嵌套,而且状态传递全靠手动管理,加一个新节点就要改一堆地方。

LangGraph.js 的核心价值在于它把"节点 + 边 + 状态"这三件事抽象出来了。你定义好每个节点做什么、节点之间怎么跳转、共享状态长什么样,剩下的调度、循环、条件分支它帮你管。更关键的是它原生支持流式输出和中断恢复,这两个在简历工具里都是刚需。

还有一个实际好处是可视化调试。LangGraph 的状态图可以直接导出成图,你能一眼看到流程哪里绕了、哪里断了。手写状态机出 bug 的时候,你只能靠打日志一点点追。

不过要说清楚,LangGraph.js 不是银弹。如果你的任务就是单轮问答,别用它,纯属增加复杂度。它适合的是"多步骤 + 有状态 + 需要人工干预"的场景,简历工具刚好全中。

1.3 技术选型的边界:什么规模的项目适合这套组合

不是所有简历工具都要上这套。我给个判断标准:

项目类型推荐方案理由
纯简历模板填充前端模板 + 表单不需要 AI
单次简历润色单轮 Prompt API一次调用能搞定
简历 + 岗位匹配 + 多轮修改Next.js + LangGraph.js多步骤有状态
企业级批量简历筛选后端服务 + 队列 + Agent需要并发和持久化

我做的这个工具属于第三类,核心场景是"用户上传简历 + 粘贴岗位 JD + 多轮对话式修改"。如果你只是做个"一键美化简历"的小工具,真没必要上 LangGraph,杀鸡用牛刀。

另外提醒一点,LangGraph.js 目前生态还在快速迭代,API 偶尔会有 breaking change。我建议锁定版本号,别用 latest,不然某天部署上去发现跑不起来就很尴尬。我锁的是 0.2.x 的一个稳定版本,具体版本号在 package.json 里写死。

2. 项目骨架搭建与依赖版本锁定

2.1 Next.js App Router 的目录结构设计

我用的是 Next.js 14 的 App Router。目录结构这块我踩过坑,第一版把所有逻辑塞在app/api里,后来发现 Agent 的状态管理代码和路由代码混在一起,改起来很痛苦。第三版重新组织了一下:

resume-agent/ ├── app/ │ ├── api/ │ │ └── agent/ │ │ └── route.ts # 流式接口入口 │ ├── chat/ │ │ └── page.tsx # 对话主界面 │ └── layout.tsx ├── lib/ │ ├── agent/ │ │ ├── graph.ts # LangGraph 状态图定义 │ │ ├── state.ts # 状态类型定义 │ │ ├── nodes/ # 各个节点 │ │ │ ├── parseResume.ts │ │ │ ├── analyzeJD.ts │ │ │ ├── matchGap.ts │ │ │ ├── generateSuggestion.ts │ │ │ └── applyEdit.ts │ │ └── tools/ # 工具函数 │ ├── llm/ │ │ └── client.ts # 模型客户端封装 │ └── types/ │ └── resume.ts # 简历数据结构 ├── components/ │ ├── ResumeUploader.tsx │ ├── ChatPanel.tsx │ └── DiffViewer.tsx # 修改对比展示 └── package.json

关键点是把 Agent 逻辑和 Next.js 路由解耦。lib/agent里全是纯逻辑,不依赖 Next.js 的任何东西,这样单元测试好写,将来想换框架也不用重写。app/api/agent/route.ts只负责接收请求、调用 graph、把流式结果吐回去。

这个结构还有个好处是节点可以单独测试。我写了个脚本直接调parseResume节点,喂一份简历进去看输出,不用起整个 Next.js 服务,调试效率高很多。

2.2 依赖清单与版本踩坑记录

依赖这块我列个实际用的清单,版本号是我验证过能跑通的组合:

{ "dependencies": { "next": "14.2.3", "react": "18.3.1", "@langchain/langgraph": "0.2.5", "@langchain/core": "0.3.15", "@langchain/openai": "0.3.11", "zod": "3.23.8", "ai": "3.4.7" } }

踩过的坑说几个。第一个是@langchain/langgraph和@langchain/core的版本必须匹配,我一开始 core 装了个旧版本,graph 跑起来报Cannot read property 'Channel' of undefined,查了半天是版本不兼容。第二个是ai这个包(Vercel 的 AI SDK),它和 LangGraph 的流式输出格式不一样,需要做一层转换,后面流式那节细讲。

第三个坑是Node 版本。LangGraph.js 有些特性依赖 Node 18+ 的AsyncLocalStorage,我用 Node 16 跑的时候流式输出会丢状态。建议直接上 Node 20 LTS,省心。

提示:装依赖的时候别用--force或--legacy-peer-deps硬装,版本冲突就老老实实调版本号,硬装出来的依赖树后面会以各种诡异的方式报错。

2.3 环境变量与模型接入的配置细节

环境变量我用了这几个:

# .env.local OPENAI_API_KEY=sk-xxx OPENAI_BASE_URL=https://api.openai.com/v1 MODEL_NAME=gpt-4o-mini

这里有个经验:简历工具不需要用最贵的模型。我一开始用 gpt-4o,效果是好,但成本扛不住,用户改一次简历要跑七八个节点,每个节点都调一次模型。后来换成 gpt-4o-mini,配合好的 Prompt 和结构化输出,效果差距不大,成本降了十几倍。

模型客户端我封装了一层,主要是为了统一处理重试和超时:

// lib/llm/client.ts import { ChatOpenAI } from "@langchain/openai"; export const llm = new ChatOpenAI({ modelName: process.env.MODEL_NAME || "gpt-4o-mini", temperature: 0.3, maxRetries: 2, timeout: 30000, configuration: { baseURL: process.env.OPENAI_BASE_URL, }, });

temperature设 0.3 是有讲究的。简历修改需要一定的创造性(比如换个说法),但又不能太飘(比如编造经历)。0.3 是我试了 0、0.3、0.7 之后选的,0 太死板,0.7 会瞎编,0.3 刚好。

maxRetries: 2也是踩坑加的。模型 API 偶尔会抽风返回 429 或 500,不加重试的话用户那边直接看到报错,体验很差。重试两次基本能覆盖大部分偶发问题。

3. LangGraph 状态图的核心节点设计

3.1 状态结构定义:简历 Agent 的共享内存长什么样

LangGraph 的核心是状态(State)。所有节点读写同一个状态对象,节点之间通过状态传递数据。简历 Agent 的状态我定义成这样:

// lib/agent/state.ts import { Annotation } from "@langchain/langgraph"; export const ResumeState = Annotation.Root({ // 原始输入 rawResume: Annotation<string>, jobDescription: Annotation<string>, // 解析后的结构化数据 parsedResume: Annotation<ResumeData | null>, parsedJD: Annotation<JDData | null>, // 分析结果 gaps: Annotation<Gap[]>, suggestions: Annotation<Suggestion[]>, // 对话相关 messages: Annotation<BaseMessage[]>({ reducer: (prev, next) => prev.concat(next), default: () => [], }), // 当前阶段 stage: Annotation<string>, // 用户反馈 userFeedback: Annotation<string | null>, });

这里有几个设计决策值得说。messages用了reducer,意思是每次节点返回新消息时,是追加而不是覆盖。这是 LangGraph 里处理对话历史的标准做法,不写 reducer 的话每次都会被覆盖掉。

parsedResume和parsedJD用null作为初始值,是为了区分"还没解析"和"解析了但是空的"。这个区分在条件路由里很有用,后面讲路由的时候会用到。

stage字段是我自己加的,用来标记当前流程走到哪一步。LangGraph 本身有节点名,但节点名是给调度用的,stage是给业务逻辑和前端展示用的。比如前端要根据stage决定显示"正在解析简历"还是"正在生成建议"。

3.2 简历解析节点:从非结构化文本到结构化数据

简历解析是整个流程的第一步,也是最容易出问题的一步。用户上传的简历格式千奇百怪,PDF、Word、纯文本都有,而且排版五花八门。我的做法是先用工具把文件转成纯文本,再让模型做结构化提取。

// lib/agent/nodes/parseResume.ts import { z } from "zod"; import { llm } from "@/lib/llm/client"; const ResumeSchema = z.object({ basicInfo: z.object({ name: z.string(), email: z.string().optional(), phone: z.string().optional(), }), education: z.array(z.object({ school: z.string(), major: z.string(), degree: z.string(), period: z.string(), })), experience: z.array(z.object({ company: z.string(), role: z.string(), period: z.string(), highlights: z.array(z.string()), })), skills: z.array(z.string()), }); export async function parseResume(state: typeof ResumeState.State) { const structured = llm.withStructuredOutput(ResumeSchema); const result = await structured.invoke([ { role: "system", content: `你是一个简历解析专家。从下面的简历文本中提取结构化信息。 要求: 1. 不要编造任何信息,原文没有的字段留空 2. highlights 提取工作经历中的具体成果,保留数字 3. skills 去重并归类`, }, { role: "user", content: state.rawResume }, ]); return { parsedResume: result, stage: "parsed", }; }

这里用了withStructuredOutput+ Zod schema,这是 LangChain 里做结构化输出的标准姿势。好处是模型返回的 JSON 会被自动校验,不符合 schema 会重试。我试过不用 schema 直接让模型返回 JSON,十次里有两次格式是错的,加了 schema 之后基本没出过错。

Prompt 里那句"不要编造任何信息"是必须的。我测试的时候发现,如果简历里没写邮箱,模型会"贴心"地编一个example@email.com出来。这种编造在简历场景是致命的,用户拿去投递就露馅了。

还有一个细节是highlights的提取。我要求保留数字,因为简历里最有价值的就是量化成果。模型有时候会把"提升了 30% 效率"简化成"提升了效率",这就把关键信息丢了。明确要求保留数字之后,提取质量明显提升。

3.3 岗位匹配节点:把 JD 拆成可对比的维度

岗位匹配是简历工具的核心价值点。用户想知道的是"我的简历和这个岗位差在哪",而不是"这个岗位要求什么"。所以这个节点的任务是把 JD 拆成可对比的维度,然后和简历逐项对比。

// lib/agent/nodes/matchGap.ts const GapSchema = z.object({ gaps: z.array(z.object({ dimension: z.string(), // 维度:技能/经验/学历等 requirement: z.string(), // 岗位要求 current: z.string(), // 简历现状 severity: z.enum(["high", "medium", "low"]), suggestion: z.string(), // 改进方向 })), }); export async function matchGap(state: typeof ResumeState.State) { const structured = llm.withStructuredOutput(GapSchema); const result = await structured.invoke([ { role: "system", content: `对比简历和岗位要求,找出差距。 severity 判断标准: - high: 岗位硬性要求,简历完全没有 - medium: 岗位要求,简历有但不突出 - low: 加分项,简历可以补充`, }, { role: "user", content: `简历:${JSON.stringify(state.parsedResume)} 岗位:${JSON.stringify(state.parsedJD)}`, }, ]); return { gaps: result.gaps, stage: "matched" }; }

severity这个字段是我加了之后觉得最值的设计。一开始没有分级,所有差距平铺给用户,用户看完一脸懵,不知道先改哪个。加了 severity 之后,前端可以按严重程度排序,用户一眼看到"哦,这个岗位要求 Kubernetes,我简历里完全没提,这是 high"。

Prompt 里对 severity 的判断标准写得很具体,这是关键。如果你只写"判断严重程度",模型会按自己的理解来,同一个差距这次判 high 下次判 medium。给了明确标准之后,一致性好了很多。

3.4 建议生成与修改应用节点的拆分逻辑

建议生成和修改应用我拆成了两个节点,这是有意的。一开始我合成一个节点,让模型直接输出修改后的简历,结果发现两个问题:一是用户看不到"改了什么",二是用户想只采纳部分建议时没法操作。

拆开之后,generateSuggestion节点只负责生成建议列表,每条建议包含"原文"和"建议改成"。applyEdit节点负责把用户选中的建议应用到简历上。

// lib/agent/nodes/generateSuggestion.ts const SuggestionSchema = z.object({ suggestions: z.array(z.object({ id: z.string(), target: z.string(), // 针对简历的哪个部分 original: z.string(), // 原文 revised: z.string(), // 建议改成 reason: z.string(), // 修改理由 })), });

original和revised这两个字段是 DiffViewer 组件的基础。前端拿到这两个字段就能渲染出"删除线 + 高亮"的对比效果,用户一眼看到改了什么。这个体验比"直接给一份新简历"好太多,用户有掌控感。

reason字段也不能省。用户不是无脑接受建议的,你得告诉他为什么这么改。比如"把'负责项目管理'改成'主导 3 人团队完成 XX 项目,提前 2 周交付',因为原表述太笼统,缺乏量化成果"。有了理由,用户才会信任这个工具。

4. 状态图的边与条件路由设计

4.1 主流程的线性边与入口点设置

节点定义好了,接下来是把它们连起来。LangGraph 里用addEdge连线性流程,用addConditionalEdges连条件分支。

// lib/agent/graph.ts import { StateGraph, START, END } from "@langchain/langgraph"; import { ResumeState } from "./state"; import { parseResume } from "./nodes/parseResume"; import { analyzeJD } from "./nodes/analyzeJD"; import { matchGap } from "./nodes/matchGap"; import { generateSuggestion } from "./nodes/generateSuggestion"; import { applyEdit } from "./nodes/applyEdit"; const workflow = new StateGraph(ResumeState) .addNode("parseResume", parseResume) .addNode("analyzeJD", analyzeJD) .addNode("matchGap", matchGap) .addNode("generateSuggestion", generateSuggestion) .addNode("applyEdit", applyEdit) .addEdge(START, "parseResume") .addEdge("parseResume", "analyzeJD") .addEdge("analyzeJD", "matchGap") .addEdge("matchGap", "generateSuggestion") .addConditionalEdges("generateSuggestion", routeAfterSuggestion) .addEdge("applyEdit", END); export const graph = workflow.compile();

START和END是 LangGraph 的内置常量,分别代表图的入口和出口。START连到parseResume,意思是流程从解析简历开始。

这里有个细节:parseResume和analyzeJD其实可以并行,因为它们互不依赖。LangGraph 支持并行节点,但我没这么做,原因是并行会让状态更新变复杂,而且这两个节点都调模型,并行反而可能触发 API 限流。串行虽然慢一点,但稳定。

4.2 条件路由:用户不满意时如何回退重生成

routeAfterSuggestion是条件路由函数,决定生成建议之后往哪走:

function routeAfterSuggestion(state: typeof ResumeState.State) { if (state.userFeedback === "reject") { return "generateSuggestion"; // 用户不满意,重新生成 } if (state.userFeedback === "accept") { return "applyEdit"; // 用户接受,应用修改 } return END; // 等待用户输入 }

这个路由实现了"用户不满意就重新生成"的循环。注意返回"generateSuggestion"会回到同一个节点,形成循环。LangGraph 允许这种自循环,但你要注意加循环上限,不然模型一直生成不出用户满意的,就会无限循环烧 token。

加循环上限的做法是在状态里加个计数器:

retryCount: Annotation<number>({ reducer: (prev, next) => next, default: () => 0, }),

然后在generateSuggestion节点里每次加一,路由函数里判断超过 3 次就强制走applyEdit或END。我设的上限是 3,实测下来 3 次还生成不出用户满意的,基本是需求本身有问题,再循环也没用。

4.3 中断与人工介入:LangGraph 的 interrupt 机制

简历工具必须支持人工介入,因为"改得好不好"是主观判断。LangGraph 提供了interrupt机制,可以在某个节点后暂停,等外部输入再继续。

import { interrupt } from "@langchain/langgraph"; export async function generateSuggestion(state) { // ... 生成建议逻辑 // 暂停,等待用户反馈 const feedback = interrupt({ type: "suggestion_review", suggestions: result.suggestions, }); return { suggestions: result.suggestions, userFeedback: feedback, }; }

interrupt会抛出一个特殊的中断信号,图执行到这里会暂停,把当前状态存起来。前端拿到建议列表展示给用户,用户操作后带着反馈重新调用,图从暂停的地方继续。

这个机制是 LangGraph 相比手写状态机最大的优势之一。手写的话你得自己实现"暂停-保存-恢复",还要处理并发和状态序列化,很麻烦。LangGraph 内置了这套,配合它的 checkpointer 就能实现持久化的中断恢复。

不过要注意,interrupt需要配合checkpointer使用,不然状态存不下来。checkpointer 我用的MemorySaver做开发,生产环境换成了基于数据库的实现。MemorySaver 重启就丢,只能开发用。

5. 流式输出与前端交互的打通

5.1 Next.js Route Handler 里的流式响应

Agent 跑起来可能要好几十秒,用户不能干等着。流式输出是必须的。Next.js 的 Route Handler 支持返回ReadableStream,配合 LangGraph 的streamEvents就能实现。

// app/api/agent/route.ts import { graph } from "@/lib/agent/graph"; import { NextRequest } from "next/server"; export async function POST(req: NextRequest) { const { message, threadId } = await req.json(); const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { const events = graph.streamEvents( { messages: [{ role: "user", content: message }] }, { version: "v2", configurable: { thread_id: threadId } } ); for await (const event of events) { if (event.event === "on_chat_model_stream") { const chunk = event.data.chunk?.content; if (chunk) { controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: "token", content: chunk })}\n\n`) ); } } if (event.event === "on_chain_end" && event.name === "parseResume") { controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: "stage", stage: "parsed" })}\n\n`) ); } } controller.close(); }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", }, }); }

这里用的是SSE(Server-Sent Events)格式,每条消息以data:开头,以\n\n结尾。这是浏览器原生支持的服务端推送格式,比 WebSocket 简单,单向推送够用了。

streamEvents的version: "v2"是必须的,v1 的事件格式不一样。我一开始没写 version,事件名对不上,调了半天。

事件类型里,on_chat_model_stream是模型逐 token 输出,on_chain_end是某个节点执行完。我利用on_chain_end来推送阶段变化,前端收到stage: "parsed"就知道简历解析完了,可以更新 UI 提示。

5.2 前端消费流式数据的完整实现

前端消费 SSE 用fetch+ReadableStream读取:

// components/ChatPanel.tsx async function sendMessage(message: string) { const res = await fetch("/api/agent", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message, threadId }), }); const reader = res.body?.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader!.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n\n"); buffer = lines.pop() || ""; for (const line of lines) { if (!line.startsWith("data: ")) continue; const data = JSON.parse(line.slice(6)); if (data.type === "token") { setCurrentText((prev) => prev + data.content); } else if (data.type === "stage") { setStage(data.stage); } } } }

这里有个必须处理的细节:SSE 的数据可能被 TCP 分片,一次read()拿到的不是完整的一条消息。所以要用buffer累积,按\n\n分割,最后一段可能不完整,留在 buffer 里等下次。我第一版没处理这个,偶尔会出现 JSON 解析错误,加了 buffer 之后就好了。

decoder.decode(value, { stream: true })里的stream: true也很关键。多字节字符(比如中文)可能被分片切断,不加这个参数会解码出乱码。

5.3 阶段状态同步:让用户知道 Agent 在干什么

Agent 跑几十秒,用户最怕的是"不知道在干嘛"。所以阶段状态同步很重要。我在状态里定义了stage字段,每个节点执行完更新它,前端根据stage显示不同的提示。

stage 值前端显示用户感知
parsing正在解析简历...知道系统在读简历
parsed简历解析完成看到结构化结果
analyzing正在分析岗位...知道系统在读 JD
matched匹配分析完成看到差距列表
generating正在生成建议...知道系统在思考
reviewing请确认修改建议可以操作了

这个表是我实际用的,每个阶段对应一个 UI 状态。用户看到进度在推进,等待焦虑就小很多。实测下来,加了阶段提示之后,用户中途放弃的比例明显下降。

阶段同步还有个好处是出错时好定位。如果卡在parsing不动了,那肯定是简历解析节点出问题了,排查范围一下就缩小了。

6. 部署上线与生产环境的坑

6.1 从本地到生产的配置差异

本地跑通不代表生产能跑。我部署的时候踩了几个坑,一个个说。

第一个是环境变量。本地用.env.local,生产环境(我用的 Vercel)要在控制台配。坑在于OPENAI_BASE_URL这种带默认值的变量,本地不配也能跑(走默认),生产不配就报错。建议所有环境变量都显式配置,别依赖默认值。

第二个是超时。Vercel 的 Serverless Function 默认超时是 10 秒(Hobby 计划),Agent 跑一次要几十秒,直接超时。解决办法是升级到 Pro 计划(60 秒)或者用流式响应。流式响应有个好处是只要开始返回数据,连接就不会因为超时断开。我用的流式,所以 Hobby 计划也能跑。

第三个是冷启动。Serverless 冷启动要几秒,用户第一次请求会感觉特别慢。我的做法是在页面加载时先发一个预热请求,把函数唤醒。这个技巧不优雅但有效。

6.2 模型调用的成本控制与限流

成本这块必须算清楚。我统计过,用户完整改一次简历,大概要调 6-8 次模型,每次平均 2000 token 输入 + 500 token 输出。用 gpt-4o-mini 的话,一次大概 0.002 美元,一天 1000 次请求就是 2 美元。听起来不多,但如果被刷或者有 bug 导致循环,成本会失控。

我的控制措施有几个。一是循环上限,前面说的 retryCount 限制 3 次。二是输入截断,简历文本超过 8000 字符就截断,避免超长输入。三是限流,用 Next.js 的 middleware 做了个简单的 IP 限流,每分钟最多 10 次请求。

// middleware.ts const rateLimit = new Map<string, { count: number; reset: number }>(); export function middleware(req: NextRequest) { const ip = req.ip || "unknown"; const now = Date.now(); const record = rateLimit.get(ip); if (!record || now > record.reset) { rateLimit.set(ip, { count: 1, reset: now + 60000 }); return NextResponse.next(); } if (record.count >= 10) { return new NextResponse("Too many requests", { status: 429 }); } record.count++; return NextResponse.next(); }

这个限流是内存版的,Serverless 环境下每个实例独立,不够精确,但能挡住大部分滥用。要精确限流得上 Redis,看你的规模决定。

6.3 简历数据的安全处理与隐私边界

简历包含大量个人隐私信息,这块必须谨慎。我的处理原则是最小化存储 + 及时清理。

具体做法:简历原文只在内存里处理,不落库。解析后的结构化数据如果用户不保存,请求结束就丢。如果用户选择保存,只存脱敏后的版本(去掉手机号、邮箱等敏感字段),而且给用户明确的删除入口。

模型调用这块,我用的是 API 模式,数据会发给模型服务商。这一点必须在隐私政策里写清楚,让用户知情。如果对隐私要求极高,可以考虑本地部署模型,但成本和效果要权衡。

注意:简历数据涉及个人信息,处理时务必遵守相关法律法规,做好用户告知和授权。不要为了功能便利而过度收集或存储用户数据。

还有个细节是日志。调试的时候很容易把简历内容打进日志,生产环境这是大忌。我在日志里对简历内容做了脱敏,只记录长度和结构,不记录具体内容。

7. 几个让我印象深刻的调试案例

7.1 状态丢失:为什么节点间数据传不过去

有一次遇到个诡异的问题:parseResume节点明明返回了parsedResume,但matchGap节点里读到的却是null。查了半天,发现是状态字段没在 Annotation.Root 里声明。

LangGraph 的状态是严格声明的,你返回一个没在 schema 里定义的字段,它会被静默丢弃。我一开始以为返回什么就存什么,结果不是。这个坑很隐蔽,因为不报错,只是数据没了。

解决办法就是确保所有要传递的字段都在Annotation.Root里声明。我后来养成了习惯,加新字段先改 state.ts,再写节点逻辑。

7.2 流式中断:SSE 连接被意外关闭的排查

流式输出偶尔会中途断掉,前端收到一半就没数据了。排查发现是反向代理的缓冲。Vercel 的边缘网络会对响应做缓冲,如果响应头没设置对,它会等整个响应完成才一次性返回,流式就失效了。

解决办法是在响应头里加X-Accel-Buffering: no,明确告诉代理不要缓冲。另外Cache-Control: no-cache也要加,避免被缓存。

headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", },

这个坑我查了挺久,因为本地开发环境没有代理,流式是正常的,一部署就出问题。后来才意识到是代理层的锅。

7.3 模型幻觉:简历里凭空多出来的经历

最严重的一次 bug 是模型在修改简历时编造了一段工作经历。用户简历里没有这段,模型"觉得"加上会更好,就加上了。这种幻觉在简历场景是灾难性的。

排查下来,问题出在 Prompt 上。我当时的 Prompt 是"优化这份简历,让它更有竞争力",这个指令太开放,模型就自由发挥了。改成"只修改用户指定的部分,不得新增任何原文没有的经历"之后,幻觉基本消失。

另外我加了一道校验:修改后的简历和原文做对比,如果新增了原文没有的公司名或时间段,就拦截并重新生成。这个校验用简单的字符串匹配就能做,成本很低但很有效。

function validateNoHallucination(original: string, revised: string) { const originalCompanies = extractCompanies(original); const revisedCompanies = extractCompanies(revised); const newCompanies = revisedCompanies.filter(c => !originalCompanies.includes(c)); if (newCompanies.length > 0) { throw new Error(`检测到编造的公司:${newCompanies.join(", ")}`); } }

这个校验函数是我从踩坑里总结出来的,强烈建议加上。模型幻觉防不住,但可以检测和拦截。

8. 后续可以继续深挖的方向

这套东西跑通之后,我还在继续迭代。有几个方向我觉得挺有价值,分享给想深入的同学。

第一个是多岗位对比。现在只能对比一个岗位,用户经常想同时看自己适合哪几个岗位。这个扩展需要在状态里支持多个 JD,匹配节点改成循环处理。LangGraph 的SendAPI 支持动态并行,适合这种场景。

第二个是简历版本管理。用户改简历是个反复的过程,需要能回退到任意版本。这个可以用 LangGraph 的 checkpointer 实现,每个版本存一个 checkpoint,用户选择回退到哪个。

第三个是面试问题预测。基于简历和岗位,预测面试官可能问什么。这个可以作为 Agent 的一个新分支,在matchGap之后加一个predictQuestions节点。

第四个是本地模型部署。如果对隐私要求高,可以把模型换成开源的本地部署。LangChain 支持多种模型后端,切换成本不高,但效果和成本要重新评估。

我个人在实际操作中的体会是,Agent 架构的价值不在于"用了多先进的框架",而在于它把复杂任务拆成了可管理、可调试、可回退的步骤。简历工具只是其中一个应用场景,这套"状态图 + 节点 + 条件路由"的思路,放到任何多步骤 AI 任务里都适用。真正难的不是写代码,是想清楚每个节点该做什么、状态该怎么流转、出错时怎么兜底。把这三件事想明白了,代码反而是最简单的部分。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/8 4:50:05

Space Bunny匿名模型调用量登顶:OpenRouter与OpenCode接入实战指南

1. 从调用量榜单说起&#xff1a;Space Bunny 到底是个什么来头最近一段时间&#xff0c;模型调用量榜单上出现了一个挺有意思的现象&#xff1a;一个叫 Space Bunny 的模型&#xff0c;调用量一路往上冲&#xff0c;甚至一度坐上了全球调用量第一的位置&#xff0c;把不少老牌…

作者头像 李华
网站建设 2026/10/8 4:50:02

抚仙湖流域矢量边界与DEM高程底图数据制作全流程

简介&#xff1a;这份资源面向从事流域分析、生态环境监测与水文地理建模的科研人员和GIS学习者&#xff0c;提供抚仙湖流域矢量边界及DEM高程的成套空间数据。包内共18个文件&#xff0c;约186.54MB&#xff0c;涵盖可编辑的ArcGIS MXD工程文件、标准Shapefile矢量边界、高精度…

作者头像 李华
网站建设 2026/10/8 4:48:51

学术报告 PPT 智能提纲生成:将万字论文浓缩为 15 分钟学术演讲结构

每到学期过半或顶会召开前夕&#xff0c;教研室里最让人头疼的事莫过于做学术报告 PPT。面对动辄十几页双栏、上万字公式与实验数据的论文&#xff0c;很多同学做出来的幻灯片往往成了“灾难现场”&#xff1a;把论文摘要整段复制到页面上&#xff0c;密密麻麻的小四号字挤满屏…

作者头像 李华
网站建设 2026/10/8 4:48:32

游戏引擎基础架构:数学库、内存管理与渲染流水线深度耦合

1. 这不是教科书&#xff0c;是我在引擎组熬了七年写下的第一份架构手记“游戏引擎架构深度解析&#xff08;一&#xff09;&#xff1a;引擎基础架构”——这个标题看着像学院派论文&#xff0c;但我要说清楚&#xff1a;它不是给你讲概念的&#xff0c;是给你拆螺丝的。我从2…

作者头像 李华
网站建设 2026/10/8 4:48:30

AI Agent能力扩展:Skill、MCP与插件的关系与实战指南

1. 先把概念掰开揉碎&#xff1a;Skill、MCP、插件到底各管什么1.1 三个词被混用&#xff0c;是绝大多数人踩的第一个坑我接触 AI Agent 这一摊子事大概两年多&#xff0c;从最早的纯 Prompt 编排&#xff0c;到后来接工具调用&#xff0c;再到现在的 Skill、MCP、插件满天飞&a…

作者头像 李华
网站建设 2026/10/8 4:47:38

AI编程智能体实战指南:从架构原理到工作流落地与避坑

1. 为什么“AI 编程智能体”成了程序员圈子里最热的话题最近半年&#xff0c;不管你是刷技术社区、看群聊&#xff0c;还是跟同行吃饭&#xff0c;大概率都绕不开一个词——AI 编程智能体。有人把它捧成“普通程序员逆天改命的下一个风口”&#xff0c;也有人冷眼旁观&#xff…

作者头像 李华