1. 为什么我选择用 Next.js 加 LangGraph.js 来做简历工具 Agent
1.1 从一份简历的痛点说起
做招聘的朋友应该都有体会,筛简历这件事本质上是个信息匹配问题,但真正干起来远没有说起来那么轻松。一份简历里藏着教育背景、工作经历、项目经验、技能栈、时间线、职级变化等一大堆信息,HR 平均花在一份简历上的时间可能只有十几秒。而求职者这边呢,投出去的简历石沉大海,根本不知道问题出在哪——是关键词没匹配上,还是项目描述太虚,还是格式让解析器读不出来。
我最初做这个简历工具 AI Agent,就是想解决这个双向的信息不对称。求职者上传一份简历,Agent 自动做几件事:解析结构化信息、对照目标岗位 JD 做匹配度打分、指出简历里的硬伤(比如时间线断层、技能与岗位不匹配、量化成果缺失)、给出针对性的修改建议。听起来像是个普通的简历优化工具,但真正让它有价值的是 Agent 的推理链路——它不是简单地做关键词匹配,而是像一个有经验的招聘顾问那样,先理解岗位到底要什么,再逐条对照简历找证据,最后给出有依据的判断。
这个项目我选的技术栈是 Next.js 做全栈框架,LangGraph.js 做 Agent 编排。为什么是这两个,后面会详细拆。先说说这个 Agent 到底能干什么,适合谁来参考。
如果你是想入门 AI Agent 开发的前端或全栈工程师,这个项目是个很好的练手场景——它涉及结构化输出、多步骤推理、状态管理、流式响应这些 Agent 开发的核心问题,但又不像做通用助手那样抽象。如果你是做招聘产品或 HR SaaS 的开发者,这套架构可以直接迁移到简历筛选、人才匹配、面试问题生成等场景。如果你只是想给自己做一份能过 ATS 的简历,那这个工具本身也能用。
1.2 为什么是 Next.js 而不是纯后端
很多人做 AI Agent 第一反应是 Python 后端加个前端,或者干脆用 FastAPI 起个服务。我一开始也这么想,但实际做下来发现 Next.js 的全栈能力在这个场景里优势很明显。
简历工具的核心交互是"上传文件、等待处理、看到结果",这个流程里前端和后端的边界其实很模糊。用 Next.js 的 App Router,我可以在同一个项目里写页面、写 API Route、写 Server Action,不用维护两套工程。更重要的是流式响应——Agent 推理是个耗时操作,用户不可能盯着转圈等十几秒。Next.js 的 Route Handler 原生支持 ReadableStream,配合 Vercel AI SDK 或者自己手写 SSE,可以把 Agent 的中间步骤实时推给前端,用户能看到"正在解析简历""正在分析岗位要求""正在生成建议"这样的进度,体验完全不一样。
另一个考虑是部署。Next.js 可以一键部署到 Vercel,也可以自己用 Node 跑,Serverless 和长连接两种模式都支持。对于简历工具这种流量波动大的场景,Serverless 按需扩容很划算。当然这里有个坑,后面会讲——Agent 推理时间长了会撞上 Serverless 的超时限制,得做特殊处理。
1.3 LangGraph.js 解决的是什么问题
如果用 LangChain.js 的 AgentExecutor 或者简单的链式调用,做单轮问答没问题,但简历分析是个多步骤、有状态、可能分支的流程。比如解析完简历后,要根据岗位类型决定走哪条分析路径——技术岗重点看项目和技术栈,管理岗重点看团队规模和业务结果。这种带条件分支和循环的流程,用普通链式调用写出来会非常乱。
LangGraph.js 的核心价值是把 Agent 的执行流程建模成一张图。节点是具体的处理步骤,边是步骤之间的流转条件,整个图有一个共享的 State 在节点间传递。这样做的好处是流程可视化、可调试、可中断恢复。我可以在图里加一个"人工审核"节点,让 Agent 分析到一半停下来等用户确认,确认完再继续——这在简历修改建议这种场景里特别有用,用户可以先看看 Agent 的判断对不对,再决定要不要采纳。
LangGraph.js 还有个我特别喜欢的特性是 checkpointer。它可以把图的执行状态持久化下来,用户关掉页面再回来,Agent 还能从上次中断的地方继续。对于简历这种需要反复修改的场景,这个能力很关键。
2. 整体架构设计与核心模块拆解
2.1 一张图看懂整个系统
整个系统的数据流是这样的:用户在 Next.js 前端上传简历文件(PDF 或 Word),前端把文件传给 API Route,API Route 负责文件解析和预处理,然后把纯文本交给 LangGraph.js 的 Agent 图。Agent 图内部有多个节点,依次做信息抽取、岗位匹配、问题诊断、建议生成,每个节点的输出都写进共享 State。最后前端通过流式响应拿到每个节点的中间结果和最终报告。
这里有个设计决策值得说一下:文件解析放在 Agent 图外面还是里面。我一开始把 PDF 解析也做成了一个节点,后来发现没必要。文件解析是确定性的 IO 操作,不涉及 LLM 推理,放在图里反而增加了状态管理的复杂度。现在的做法是 API Route 先把文件转成纯文本,再交给图处理。这样图里的节点都是纯推理逻辑,测试起来也方便。
2.2 State 设计:Agent 的"记忆"怎么组织
LangGraph.js 的 State 是整个 Agent 的核心,它决定了节点之间能传递什么信息。我用的是 Annotation 来定义 State 的结构,大致包含这几块:
import { Annotation } from "@langchain/langgraph"; const ResumeAgentState = Annotation.Root({ resumeText: Annotation<string>, jobDescription: Annotation<string>, parsedResume: Annotation<ResumeStructure>, jobRequirements: Annotation<JobRequirement[]>, matchAnalysis: Annotation<MatchResult>, issues: Annotation<Issue[]>, suggestions: Annotation<Suggestion[]>, currentStep: Annotation<string>, });这里每个字段都有明确的用途。resumeText和jobDescription是原始输入,parsedResume是结构化后的简历,jobRequirements是从 JD 里抽出来的要求列表,matchAnalysis是匹配结果,issues是诊断出的问题,suggestions是修改建议。currentStep用来标记当前执行到哪一步,前端靠它显示进度。
用 Annotation 而不是普通对象的好处是,LangGraph 会自动处理状态的合并和更新。每个节点返回一个 Partial State,框架负责把它合并到全局 State 里。这里要注意,如果某个字段是数组类型,默认的合并策略是替换而不是追加,需要显式指定 reducer。比如issues这个字段,如果多个节点都可能往里加问题,就得写成:
issues: Annotation<Issue[]>({ reducer: (existing, update) => [...existing, ...update], default: () => [], }),这个细节我踩过坑,一开始没注意,导致后面节点加的问题把前面节点加的覆盖了,排查了半天。
2.3 节点划分:每个节点只做一件事
Agent 图里的节点划分遵循一个原则:每个节点只做一件明确的推理任务,输入输出都是 State 里的字段。我目前划分了五个核心节点。
第一个是parseResume节点,负责把简历纯文本转成结构化数据。这个节点用 LLM 做信息抽取,输出一个包含基本信息、教育经历、工作经历、项目经验、技能列表的对象。这里的关键是结构化输出,LangChain.js 提供了withStructuredOutput方法,配合 Zod schema 可以强制 LLM 返回符合格式的 JSON。
第二个是extractRequirements节点,从岗位 JD 里抽取硬性要求和软性要求。硬性要求比如学历、年限、特定技术栈,软性要求比如沟通能力、团队协作。这个节点输出一个要求数组,每条要求带类型和权重。
第三个是matchAnalysis节点,把简历和岗位要求做逐条匹配。这个节点是 Agent 推理的核心,它不是简单做关键词匹配,而是让 LLM 判断简历里是否有证据支撑某条要求,并给出匹配程度和理由。
第四个是diagnoseIssues节点,诊断简历本身的问题。比如时间线是否有断层、项目描述是否缺少量化结果、技能列表是否与工作经历矛盾。这个节点独立于岗位匹配,是通用的简历质量检查。
第五个是generateSuggestions节点,基于前面的匹配结果和问题诊断,生成具体的修改建议。每条建议要指明针对哪个问题、怎么改、改完有什么好处。
节点之间用边连接,基本是线性流程,但在matchAnalysis之后有个条件分支:如果匹配度低于某个阈值,会多走一轮diagnoseIssues做深度诊断。这个分支逻辑用 LangGraph 的addConditionalEdges实现。
2.4 为什么用结构化输出而不是让 LLM 自由发挥
简历分析这个场景对输出的格式要求很高。前端要渲染匹配度进度条、问题列表、建议卡片,如果 LLM 返回的是自由文本,前端根本没法解析。所以我全程用结构化输出,每个节点的输出都定义了严格的 schema。
用 Zod 定义 schema 的好处是类型安全,TypeScript 能在编译期就发现字段不匹配的问题。而且 Zod schema 可以直接转成 JSON Schema 传给 LLM,让模型知道要返回什么格式。实测下来,用withStructuredOutput配合 Zod,结构化输出的成功率比在 prompt 里写"请返回 JSON"高很多,基本不会出现格式错误。
不过有个坑要注意,结构化输出会消耗额外的 token,因为 schema 本身要传给模型。对于简历这种长文本,token 消耗不小。我的优化是只在关键节点用结构化输出,一些中间步骤用普通文本输出,后面再解析。
3. 核心节点的实现细节与实操要点
3.1 简历解析节点:怎么让 LLM 准确抽取信息
简历解析看起来简单,实际上坑很多。最大的问题是简历格式千奇百怪,有人的工作经历是按时间倒序,有人是正序,有人把项目经验写在工作经历里面,有人单独列出来。如果直接让 LLM 抽取,它可能会把项目经验当成工作经历,或者漏掉一些信息。
我的做法是分两步走。第一步先用规则做预处理,把简历文本按章节切分。常见的章节标题有"工作经历""项目经验""教育背景""技能特长"等,用正则匹配这些标题,把文本切成块。第二步再把每个块交给 LLM 做结构化抽取,这样 LLM 只需要关注块内的信息,不用处理章节归属的问题。
const sectionPatterns = [ { key: "work", patterns: [/工作经历/, /工作经验/, /职业经历/] }, { key: "project", patterns: [/项目经验/, /项目经历/, /主要项目/] }, { key: "education", patterns: [/教育背景/, /教育经历/, /学历/] }, { key: "skills", patterns: [/技能/, /专业技能/, /技术栈/] }, ];抽取工作经历时,我用的 prompt 大致是这样的:给一段工作经历文本,要求抽取公司名、职位、起止时间、主要职责、量化成果。这里的关键是要求 LLM 把"主要职责"和"量化成果"分开,因为后面诊断问题时需要判断是否有量化成果。如果混在一起,就没法准确判断。
注意:简历里的时间格式非常不统一,有"2020.03-2022.05",有"2020年3月至今",有"2020-2022"。我写了一个时间归一化函数,把这些格式统一转成
{ start: "2020-03", end: "2022-05" }的结构。这个函数用规则处理,比让 LLM 做更可靠。
3.2 岗位要求抽取:区分硬性要求和软性要求
岗位 JD 的抽取比简历简单一些,但也有讲究。JD 里经常混着公司介绍、福利待遇、岗位职责、任职要求,真正需要的是任职要求部分。我的做法是先让 LLM 识别出任职要求段落,再从中抽取具体要求。
抽取出来的要求要分类。硬性要求是那些可以明确判断是否满足的,比如"本科以上学历""3年以上工作经验""熟悉 React"。软性要求是那些比较主观的,比如"有良好的沟通能力""有团队协作精神"。分类的目的是后面匹配时权重不同,硬性要求不满足基本就挂了,软性要求可以放宽。
每条要求还要带一个权重。权重的来源有两个:一是 JD 里明确写了"必须""要求"的,权重高;写了"优先""加分"的,权重低。二是根据要求出现的频率和位置,出现在前面的、反复强调的,权重高。这个权重计算我用了一个简单的规则加 LLM 判断的混合方案。
3.3 匹配分析节点:Agent 推理的核心
匹配分析是整个 Agent 最有价值的部分,也是最难做好的部分。简单做法是拿简历里的技能列表和 JD 里的要求做交集,但这样太粗糙了。比如 JD 要求"有大型项目架构经验",简历里写"负责过日活百万的系统重构",这明显是匹配的,但关键词对不上。
我的做法是让 LLM 对每条要求做证据检索。具体来说,把简历的结构化数据和要求一起给 LLM,让它判断简历里是否有证据支撑这条要求,并给出匹配等级:完全匹配、部分匹配、不匹配。同时要求 LLM 引用简历里的具体内容作为证据。
const matchPrompt = ` 岗位要求:${requirement.text} 要求类型:${requirement.type} 候选人简历: ${JSON.stringify(parsedResume, null, 2)} 请判断候选人是否满足这条要求,返回: - level: "full" | "partial" | "none" - evidence: 简历中支撑判断的具体内容,如果没有则返回空字符串 - reason: 判断理由 `;这里有个技巧,要求 LLM 必须引用原文作为证据,能大幅减少幻觉。如果 LLM 找不到证据,它就只能返回"不匹配",而不是编一个理由说匹配。
匹配结果汇总后,算一个总分。硬性要求不满足的扣分多,软性要求不满足的扣分少。最后给出一个 0-100 的匹配度分数,前端用进度条展示。
3.4 问题诊断节点:找出简历的硬伤
问题诊断节点独立于岗位匹配,检查的是简历本身的通用问题。我定义了几类常见问题:
时间线问题:工作经历之间有超过 3 个月的空档,或者时间重叠。空档不一定是问题,但需要提醒用户准备解释。时间重叠则可能是写错了。
量化缺失:工作经历或项目经验里没有数字。比如"负责优化系统性能"就没有量化,"负责优化系统性能,接口响应时间从 800ms 降到 200ms"就有量化。我让 LLM 检查每条经历是否有量化成果,没有的标记出来。
技能与经历矛盾:技能列表里写了某个技术,但工作经历里完全没提到。这不一定有问题,但可能是技能堆砌,需要提醒。
描述空泛:用了大量"负责""参与""协助"这类词,但没说具体做了什么、有什么结果。这个判断比较主观,我让 LLM 给每条经历打一个"具体度"分数,低于阈值的标记出来。
实操心得:问题诊断的 prompt 里要明确告诉 LLM"宁可漏报,不要误报"。因为误报会让用户觉得工具不靠谱,漏报只是少发现一个问题。我在 prompt 里加了"只标记你有充分把握的问题"这样的约束,误报率明显下降。
3.5 建议生成节点:从问题到可执行的修改
建议生成节点要把前面的问题诊断和匹配分析结果,转化成具体的修改建议。这里的关键是建议要可执行,不能是"建议加强项目描述"这种空话,而要是"把'负责系统优化'改成'主导订单系统重构,QPS 从 500 提升到 3000,P99 延迟降低 60%'"这种可以直接抄的。
我的做法是让 LLM 针对每个问题,生成一条包含三部分的建议:问题描述、修改方向、示例改写。示例改写是最有价值的,用户可以直接参考。为了让示例改写质量高,我在 prompt 里放了几个 few-shot 例子,展示什么样的改写是好的。
建议还要排序。我按影响程度排序,影响匹配度的建议排前面,影响简历质量的排后面。每条建议带一个优先级标签,前端用不同颜色展示。
4. 前后端联调与流式响应的实现
4.1 Next.js API Route 怎么接 LangGraph
Next.js 的 App Router 里,API Route 写在app/api/xxx/route.ts里。接 LangGraph 的基本流程是:解析请求里的文件,转成文本,初始化 Agent 图,调用stream方法,把结果流式返回。
export async function POST(req: Request) { const formData = await req.formData(); const file = formData.get("resume") as File; const jd = formData.get("jd") as string; const resumeText = await parseFile(file); const stream = new ReadableStream({ async start(controller) { const graph = buildResumeGraph(); const events = await graph.stream({ resumeText, jobDescription: jd, }); for await (const event of events) { controller.enqueue( new TextEncoder().encode(JSON.stringify(event) + "\n") ); } controller.close(); }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream" }, }); }这里用的是 SSE 格式,每条消息是一个 JSON,前端按行解析。LangGraph 的stream方法会返回每个节点执行完的事件,事件里包含节点名和该节点更新后的 State。前端根据节点名显示不同的进度提示。
4.2 前端怎么消费流式数据
前端用 fetch 拿到流之后,用getReader()逐块读取,按换行符切分,解析每条 JSON。每收到一个事件,就更新对应的 UI 状态。
const reader = response.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"); buffer = lines.pop() || ""; for (const line of lines) { if (!line.trim()) continue; const event = JSON.parse(line); handleEvent(event); } }handleEvent根据事件里的节点名,更新对应的状态。比如收到parseResume节点的事件,就把解析出的简历信息渲染出来;收到matchAnalysis节点的事件,就更新匹配度进度条。
注意:流式响应里如果某个节点执行失败,要能优雅处理。我的做法是在图里加一个错误处理节点,捕获异常后往 State 里写一个 error 字段,前端收到后显示错误提示,而不是让整个流断掉。
4.3 文件解析的坑:PDF 和 Word 怎么处理
简历文件主要是 PDF 和 Word 两种格式。PDF 解析用pdf-parse或者pdfjs-dist,Word 用mammoth。这两个库在 Node 环境里都能跑,但有几个坑。
PDF 解析最大的问题是排版。有些 PDF 是扫描件,解析出来是空的;有些 PDF 是多栏排版,解析出来文字顺序是乱的。扫描件我目前不支持,直接提示用户上传文字版。多栏排版的问题,我试过用pdfjs-dist的坐标信息重新排序,但效果一般,后来改成提示用户如果解析结果不对,可以手动粘贴文本。
Word 解析相对简单,mammoth能把 docx 转成 HTML,再提取纯文本。但要注意表格里的内容,mammoth默认会把表格转成 HTML 表格,提取文本时要特殊处理,否则表格里的信息会丢失。
4.4 部署时的超时问题与解决
Agent 推理是个耗时操作,完整跑一遍五个节点,快的话十几秒,慢的话半分钟。这在本地开发没问题,但部署到 Serverless 平台就会撞上超时限制。Vercel 的免费版函数超时是 10 秒,Pro 版是 60 秒,勉强够用但不保险。
我的解决方案是把长任务拆开。前端先调一个接口做文件解析和简历结构化,这部分快,几秒内能完成。然后再调一个接口做匹配分析和建议生成,这部分慢,但可以流式返回,只要第一个字节在超时前返回,后面的流就不会被切断。
另一个方案是用 Edge Runtime,它的超时限制更宽松,而且支持流式响应。但 Edge Runtime 不支持 Node 的一些 API,比如文件系统操作,所以文件解析那部分还得放在 Node Runtime。我的做法是文件解析用 Node Runtime 的接口,Agent 推理用 Edge Runtime 的接口,两者分开部署。
5. 常见问题排查与避坑经验
5.1 LLM 输出格式不稳定怎么办
结构化输出虽然比自由文本稳定,但也不是 100% 可靠。我遇到过几种情况:LLM 返回的 JSON 里多了 markdown 代码块标记、字段类型不对(该是数组的返回了字符串)、必填字段缺失。
应对策略是三层防护。第一层是在 prompt 里明确要求"只返回 JSON,不要有任何其他内容"。第二层是用withStructuredOutput配合 Zod schema,LangChain 会自动做格式校验和重试。第三层是在代码里做兜底,如果解析失败,用默认值填充,并记录日志。
const safeParse = (text: string, schema: z.ZodSchema) => { try { const cleaned = text.replace(/```json\n?|\n?```/g, "").trim(); return schema.parse(JSON.parse(cleaned)); } catch (e) { console.error("Parse failed:", e, text); return null; } };5.2 Token 超限怎么处理
简历加上 JD,再加上各个节点的 prompt 和 schema,很容易超过模型的上下文窗口。我遇到过几次 token 超限的报错,后来做了几个优化。
一是简历文本做截断。如果简历超过一定长度,只保留最近的工作经历和项目经验,早期的经历做摘要。二是 JD 只保留任职要求部分,公司介绍和福利待遇直接丢掉。三是节点之间传递数据时,只传必要的字段,不要把整个 State 都塞进 prompt。
如果还是超限,就得换模型或者做分块处理。分块处理是把简历切成多块,分别做匹配,最后汇总。但这样会丢失跨块的信息,比如工作经历和项目经验的关联,所以是最后的选择。
5.3 匹配结果不准怎么调
匹配不准通常有几个原因。一是简历结构化没做好,信息抽取错了,后面匹配自然不准。二是 prompt 里对匹配标准的描述不清晰,LLM 理解偏差。三是模型能力不够,判断不了复杂的语义匹配。
排查顺序是先看结构化结果对不对,再看 prompt 有没有歧义,最后考虑换模型。我实测下来,同样的 prompt,不同模型的匹配准确率差别很大。对于简历匹配这种需要语义理解的场景,建议用能力强的模型,不要为了省钱用小的。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 流式响应中断 | Serverless 超时 | 查看函数日志 | 拆分接口,用 Edge Runtime |
| 结构化输出失败 | prompt 有歧义 | 打印 LLM 原始输出 | 加 few-shot 例子,用 Zod 校验 |
| 匹配度普遍偏低 | 要求抽取太严 | 检查要求列表 | 调整抽取 prompt,放宽标准 |
| 简历解析乱序 | PDF 多栏排版 | 查看解析文本 | 提示用户手动粘贴 |
| Token 超限 | 输入太长 | 统计 token 数 | 截断简历,精简 prompt |
| 建议太笼统 | prompt 缺少约束 | 检查建议内容 | 加 few-shot,要求给示例改写 |
5.5 几个我踩过的坑
第一个坑是 State 的 reducer。前面提过,数组字段默认是替换,多个节点往同一个数组里加数据会覆盖。这个坑让我丢了一整轮的问题诊断结果,排查了很久才发现是 reducer 的问题。
第二个坑是 LangGraph 的节点命名。节点名不能重复,而且会出现在流式事件里,前端靠它判断当前步骤。我一开始用了中文节点名,结果在某些环境下编码有问题,后来全改成英文。
第三个坑是文件上传的大小限制。Next.js 默认的 body 大小限制是 4MB,简历文件一般不会超,但如果用户上传的是扫描版 PDF,可能就超了。需要在next.config.js里调整bodyParser的大小限制,或者用流式上传。
第四个坑是并发问题。如果多个用户同时用,每个请求都会创建一个新的 Agent 图实例。LangGraph 的图实例是可以复用的,但 State 是每次请求独立的。我一开始把图实例做成了全局单例,结果 State 串了,后来改成每次请求创建新实例,或者用 checkpointer 做隔离。
6. 这套架构还能怎么扩展
6.1 从简历工具到招聘全流程
这套架构的核心是"文档解析加多步骤推理加结构化输出",这个模式可以迁移到招聘的其他环节。比如面试问题生成——输入简历和 JD,Agent 分析候选人的强项和弱项,生成针对性的面试问题。再比如候选人排序——批量处理简历,按匹配度排序,输出推荐名单。
扩展的关键是把 Agent 图做成可配置的。不同的任务用不同的节点组合,共享底层的解析和匹配能力。LangGraph 支持子图,可以把通用的节点封装成子图,不同任务复用。
6.2 加入人工反馈循环
现在的 Agent 是单向的,分析完就结束了。更好的做法是加入人工反馈,让用户对 Agent 的判断做确认或修正,修正结果反馈给 Agent 做学习。LangGraph 的 checkpointer 和 interrupt 机制很适合做这个——Agent 分析到某个节点停下来,等用户确认,确认完继续。
比如匹配分析节点,Agent 给出匹配结果后暂停,让用户看看判断对不对。如果用户觉得某条判断错了,可以手动修正,修正后的结果作为后续建议生成的输入。这样既提高了准确性,又让用户有参与感。
6.3 多模型路由降本
不同节点的推理难度不一样,没必要都用最贵的模型。简历解析这种信息抽取任务,用中等模型就够了;匹配分析这种需要语义理解的,用强模型;建议生成这种需要创造性的,也用强模型。可以在节点级别配置模型,LangGraph 的节点函数里可以自由选择用哪个模型。
更进一步,可以根据输入复杂度动态选模型。简历简单、JD 简单的,用便宜模型;复杂的用贵模型。这个判断本身也可以用一个小模型来做,成本很低。
6.4 本地化部署的考虑
有些用户对数据隐私敏感,不希望简历上传到云端。这套架构可以改成完全本地部署——Next.js 跑在本地,LangGraph 跑在本地,LLM 用本地部署的开源模型。虽然本地模型能力不如云端,但对于简历解析这种任务,中等规模的开源模型也能用。
本地部署的关键是模型选型和量化。7B 到 13B 的模型,经过 4-bit 量化,在消费级显卡上能跑。推理速度慢一些,但简历分析不是实时交互,用户能接受等待。
我在实际使用中发现,这套架构最值钱的地方不是某个具体的技术点,而是把"文档理解加多步骤推理加结构化输出"这个模式跑通了。一旦跑通,换任何文档类型、任何分析任务,改的都是 prompt 和 schema,架构本身不用动。这也是我为什么推荐用 LangGraph.js 而不是自己手写流程控制——它把状态管理、流程编排、中断恢复这些脏活累活都封装好了,你只需要关注每个节点做什么。
最后分享一个小技巧:调试 Agent 的时候,把每个节点的输入输出都打到日志里,用console.log或者写文件都行。Agent 的问题往往不是出在某个节点,而是节点之间的数据传递出了问题。能看到每个节点的输入输出,排查效率会高很多。我现在的做法是在开发环境把每个节点的 State 快照存下来,出问题时直接回放,比一步步打断点快多了。