1. 为什么我要用 Next.js + LangGraph.js 做简历工具 Agent
先说说这个项目的来龙去脉。我在日常工作中经常帮朋友看简历,也帮团队筛过不少候选人,慢慢发现一个很现实的问题:大部分人写简历不是能力不行,而是不知道怎么把能力“翻译”成招聘方想看的样子。市面上的简历工具我几乎试了个遍,要么是纯模板填充,要么是套个通用大模型的壳做润色,改出来的东西千篇一律,甚至会把真实经历改得面目全非。
所以我决定自己动手做一个真正能“读懂”简历、能跟用户来回对话、能按目标岗位定向优化的 AI Agent。技术选型上,前端用Next.js,Agent 编排用LangGraph.js。这两个组合不是拍脑袋定的,下面我会把每个选择背后的理由讲透。
这个项目适合谁看?如果你已经会一点 React 或者 Node.js,想搞清楚 AI Agent 到底怎么从 Demo 落到一个真实可用的产品里,那这篇就是写给你的。如果你只是想找个简历模板,那可能帮助不大。全文我会围绕架构设计、核心实现、踩坑记录三条线展开,把能直接抄的代码和配置都放出来。
先说清楚这个 Agent 到底要干什么,不然架构无从谈起。我给它定义了四个核心能力:第一,解析用户上传的简历(PDF/Word),结构化提取教育、经历、技能等信息;第二,跟用户多轮对话,追问缺失的关键信息,比如“你在这个项目里具体负责哪一块”;第三,根据目标岗位 JD 做定向匹配和改写建议;第四,输出一份排版规范、可直接投递的简历。这四个能力不是线性的,中间会有大量分支和回退,这正是我选 LangGraph.js 而不是简单 Chain 的根本原因。
2. 整体架构设计与技术选型拆解
2.1 为什么前端选 Next.js 而不是纯 SPA
简历工具这个场景有个很特殊的需求:既要处理文件上传,又要做流式对话,还要考虑 SEO 和首屏速度。我一开始用 Vite + React 搭了个纯前端 SPA,结果发现两个问题。一是文件解析如果放前端做,PDF 解析库体积大得离谱,首屏加载直接奔着 3MB 去了;二是流式输出对话内容时,纯前端方案要么用 WebSocket 自己维护连接状态,要么用 SSE 但跨域和重连处理很烦。
Next.js 的 App Router 天然解决了这些。我把文件解析放在 Route Handler 里跑,前端只负责上传和展示,首屏体积压到了 400KB 以内。流式对话直接用 Route Handler 返回ReadableStream,前端用fetch加getReader()消费,不需要额外维护 WebSocket 连接。另外简历工具天然有分享需求,比如“把优化后的简历生成一个链接发给朋友看”,Next.js 的服务端渲染让这类页面可以直接被搜索引擎收录,这是纯 SPA 做不到的。
具体版本我用的是 Next.js 14 的 App Router,Node 运行时。这里有个细节要注意:LangGraph.js 目前对 Edge Runtime 的支持还不完整,尤其是涉及一些 Node 原生模块的时候会报错,所以 Route Handler 里我显式声明了export const runtime = 'nodejs'。这个坑我踩过,当时流式接口在本地跑得好好的,一部署到 Edge 环境就 500,排查了半天才发现是运行时的问题。
2.2 LangGraph.js 相比普通 Chain 的核心优势
很多人做 AI Agent 第一反应是用 LangChain 的AgentExecutor或者简单的SequentialChain。我一开始也是这么干的,但很快就撞墙了。简历优化这个流程有个特点:它不是一条直线,而是一张有环的图。比如用户上传简历后,Agent 发现工作经历缺失,要追问;用户回答后,Agent 可能觉得还不够具体,要再追问一轮;追问够了才进入改写环节。改写完之后用户可能不满意,要回到追问环节补充信息。这种“带条件分支 + 可回退”的流程,用 Chain 写会变成一堆 if-else 嵌套,维护起来是灾难。
LangGraph.js 的核心抽象就是状态图(StateGraph)。你把整个流程定义成节点(Node)和边(Edge),每个节点是一个处理函数,边决定下一步走哪里。状态(State)在所有节点之间共享和累积。这跟简历优化的心智模型完全吻合。我定义的 State 大概长这样:
// lib/agent/state.ts import { Annotation } from "@langchain/langgraph"; export const ResumeState = Annotation.Root({ // 原始简历文本 rawResume: Annotation<string>({ reducer: (_, update) => update, default: () => "", }), // 结构化后的简历数据 parsedResume: Annotation<ParsedResume | null>({ reducer: (_, update) => update, default: () => null, }), // 目标岗位 JD targetJD: Annotation<string>({ reducer: (_, update) => update, default: () => "", }), // 对话历史 messages: Annotation<BaseMessage[]>({ reducer: (current, update) => current.concat(update), default: () => [], }), // 缺失信息清单 missingFields: Annotation<string[]>({ reducer: (_, update) => update, default: () => [], }), // 当前阶段 stage: Annotation<"parse" | "interview" | "optimize" | "done">({ reducer: (_, update) => update, default: () => "parse", }), });这里messages用了concat作为 reducer,意味着每次节点返回新消息时会追加而不是覆盖,这是 LangGraph.js 里处理对话历史的标准做法。其他字段用覆盖式 reducer,因为它们是“当前最新状态”。
2.3 整体流程图与节点职责划分
整个 Agent 我拆成了五个核心节点,用一张图串起来:
- parseNode:接收原始简历文本,调用大模型做结构化提取,输出
parsedResume和missingFields。 - interviewNode:根据
missingFields生成追问问题,等待用户回答,把回答合并回parsedResume。 - optimizeNode:结合
targetJD和parsedResume,生成优化后的简历内容。 - reviewNode:让用户确认优化结果,不满意则回到 interview。
- finalizeNode:生成最终排版好的简历,标记
stage为 done。
条件边的逻辑是:parse 之后如果missingFields非空就去 interview,否则直接去 optimize;interview 之后回到 parse 做一次重新提取(因为用户补充了信息);optimize 之后去 review;review 根据用户反馈决定回 interview 还是去 finalize。
这套设计的好处是每个节点职责单一,测试的时候可以单独 mock 状态来跑。我实测下来,把节点拆细之后,调试时间至少省了一半,因为出问题能快速定位是哪个环节的锅。
3. 核心细节解析与实操要点
3.1 简历解析:怎么让大模型稳定输出结构化数据
简历解析是整个流程的地基,如果这里提取的信息不准,后面全白搭。我试过三种方案:纯 Prompt 让模型输出 JSON、用 Function Calling、用 Zod Schema 约束输出。最后选了第三种,也就是 LangChain 的withStructuredOutput配合 Zod。
原因很直接:纯 Prompt 输出 JSON 的稳定性太差,模型经常在 JSON 外面包一层解释文字,或者字段名拼错。Function Calling 稳定一些,但不同模型厂商的实现差异大,换模型就要改代码。Zod Schema 方案是 LangChain 提供的统一抽象,底层会自动适配不同模型的工具调用能力,而且 Zod 本身能在运行时校验,字段类型不对会直接报错而不是悄悄塞个错误值进去。
// lib/agent/schemas.ts import { z } from "zod"; export const ParsedResumeSchema = z.object({ basicInfo: z.object({ name: z.string().describe("候选人姓名"), email: z.string().optional().describe("邮箱"), phone: z.string().optional().describe("电话"), location: z.string().optional().describe("所在城市"), }), education: z.array(z.object({ school: z.string(), degree: z.string(), major: z.string(), startDate: z.string(), endDate: z.string(), })), workExperience: z.array(z.object({ company: z.string(), title: z.string(), startDate: z.string(), endDate: z.string(), responsibilities: z.array(z.string()).describe("具体职责,每条一句话"), achievements: z.array(z.string()).describe("量化成果,尽量带数字"), })), skills: z.array(z.string()), projects: z.array(z.object({ name: z.string(), role: z.string(), description: z.string(), techStack: z.array(z.string()), })), });这里有个关键技巧:在 describe 里写清楚期望的格式。比如achievements我特意注明“尽量带数字”,模型输出的质量会明显提升。另外responsibilities和achievements用数组而不是长字符串,是为了后续做逐条优化,颗粒度更细。
调用的时候这样写:
// lib/agent/nodes/parse.ts import { ChatOpenAI } from "@langchain/openai"; import { ParsedResumeSchema } from "../schemas"; const model = new ChatOpenAI({ modelName: "gpt-4o-mini", temperature: 0, }); const structuredModel = model.withStructuredOutput(ParsedResumeSchema, { name: "extract_resume", }); export async function parseNode(state: typeof ResumeState.State) { const result = await structuredModel.invoke([ { role: "system", content: `你是一个专业的简历解析助手。请从用户提供的简历文本中提取结构化信息。 如果某个字段在原文中找不到,不要编造,留空或返回空数组。 对于工作经历中的成果,优先提取带数字的量化描述。`, }, { role: "user", content: state.rawResume }, ]); // 计算缺失字段 const missing: string[] = []; if (!result.basicInfo.email) missing.push("邮箱"); if (!result.basicInfo.phone) missing.push("电话"); if (result.workExperience.length === 0) missing.push("工作经历"); result.workExperience.forEach((exp, i) => { if (exp.achievements.length === 0) { missing.push(`第${i + 1}段工作经历的量化成果`); } }); return { parsedResume: result, missingFields: missing, stage: missing.length > 0 ? "interview" : "optimize", }; }temperature: 0是必须的,简历解析要的是稳定复现,不是创意。我试过用默认温度,同一份简历两次解析出来的字段顺序和措辞都不一样,做 diff 的时候很痛苦。
3.2 多轮追问:怎么让 Agent 问得聪明而不是像查户口
追问环节是最容易做砸的地方。我见过很多工具,一上来就是“请填写你的邮箱、电话、工作经历”,跟填表没区别,用户直接就跑了。好的追问应该是基于已有信息做推理,只问真正缺失且关键的内容。
我的做法是在 interviewNode 里,把parsedResume和missingFields一起喂给模型,让它生成一个自然的追问。关键是 Prompt 里要强调“一次只问一到两个问题”“用对话的口吻而不是问卷的口吻”。
// lib/agent/nodes/interview.ts export async function interviewNode(state: typeof ResumeState.State) { const missing = state.missingFields; // 如果缺失项太多,优先问最重要的 const priorityOrder = ["工作经历", "量化成果", "电话", "邮箱"]; const sorted = missing.sort( (a, b) => priorityOrder.findIndex(p => a.includes(p)) - priorityOrder.findIndex(p => b.includes(p)) ); const toAsk = sorted.slice(0, 2); const response = await model.invoke([ { role: "system", content: `你是一个友好的简历顾问。用户正在完善简历,目前缺少以下信息:${toAsk.join("、")}。 请用自然对话的方式追问,一次最多问两个问题。 不要用"请填写"这种命令式语气,要像朋友聊天一样。 如果用户之前已经回答过类似问题,不要重复问。`, }, ...state.messages, ]); return { messages: [response], stage: "interview", }; }这里有个细节:state.messages里包含了完整对话历史,所以模型知道之前问过什么。但要注意消息不能无限增长,否则 token 会爆。我的做法是在 messages 超过 20 条时,用一个 summarize 节点把早期对话压缩成摘要。这个优化后面在并发那节会详细讲。
用户回答之后,怎么把回答合并回parsedResume?我的做法是回到 parseNode 重新跑一遍,但这次把用户的补充信息作为额外上下文传进去。这样比手动写合并逻辑可靠得多,因为模型能理解“用户说的‘那个项目’指的是哪个项目”这种指代关系。
3.3 定向优化:怎么让改写有针对性而不是泛泛而谈
优化环节的核心是把 JD 和简历做匹配。我的做法分两步:先让模型分析 JD 的关键要求,再让模型针对每条要求去简历里找对应证据,找不到的就标记为“需要补充”。
// lib/agent/nodes/optimize.ts const JDAnalysisSchema = z.object({ keyRequirements: z.array(z.object({ requirement: z.string(), importance: z.enum(["high", "medium", "low"]), })), keywords: z.array(z.string()).describe("JD 中出现的高频技术词和业务词"), }); export async function optimizeNode(state: typeof ResumeState.State) { const jdAnalysis = await model .withStructuredOutput(JDAnalysisSchema) .invoke(`分析以下 JD,提取核心要求和关键词:\n${state.targetJD}`); const optimizationPrompt = ` 你是一个资深简历优化师。请根据以下 JD 分析结果,优化候选人的简历。 JD 核心要求: ${jdAnalysis.keyRequirements.map(r => `- [${r.importance}] ${r.requirement}`).join("\n")} JD 关键词:${jdAnalysis.keywords.join("、")} 候选人当前简历: ${JSON.stringify(state.parsedResume, null, 2)} 优化要求: 1. 对于每条 high 重要性的要求,在简历中找到对应证据,如果没有,明确标注"缺少相关经历"。 2. 改写工作经历描述,使用 JD 中的关键词,但不要编造未发生的事实。 3. 量化成果要保留原始数字,不要夸大。 4. 技能列表按与 JD 的相关度排序。 `; const optimized = await model.invoke(optimizationPrompt); return { messages: [optimized], stage: "optimize", }; }这里最重要的原则是不编造。我在 Prompt 里反复强调这一点,因为简历造假是红线。模型有时候会“好心”帮你补一些听起来合理的经历,这在简历场景里是致命的。我的做法是在 reviewNode 里让用户逐条确认,任何模型新增的内容都要用户明确点头才能保留。
4. 实操过程与核心环节实现
4.1 项目初始化与依赖安装
先把项目搭起来。我用的是create-next-app,选 TypeScript、App Router、Tailwind。
npx create-next-app@latest resume-agent --typescript --tailwind --app --no-src-dir cd resume-agent然后装核心依赖:
npm install @langchain/langgraph @langchain/openai @langchain/core npm install zod pdf-parse mammoth npm install -D @types/pdf-parse这里解释下每个包的用途。@langchain/langgraph是状态图核心,@langchain/openai是模型接入,@langchain/core提供消息类型等基础抽象。pdf-parse和mammoth分别处理 PDF 和 Word 文件。注意pdf-parse有个坑,它默认会尝试读取一个测试文件,在 Next.js 的打包环境里会报错,需要在next.config.js里配置:
// next.config.js module.exports = { webpack: (config) => { config.resolve.alias.canvas = false; config.resolve.alias.encoding = false; return config; }, };这个坑我卡了整整一个下午,报错信息是ENOENT: no such file or directory,完全看不出跟 pdf-parse 有关,最后是在 GitHub issue 里翻到的。
4.2 文件上传与解析接口实现
文件上传我用的是 Next.js 的 Route Handler,配合 FormData。这里要注意 Next.js 14 里request.formData()对文件大小的限制,默认是 4MB,简历一般够用,但如果要放宽可以在 Route Handler 配置里改。
// app/api/upload/route.ts import { NextRequest, NextResponse } from "next/server"; import pdf from "pdf-parse"; import mammoth from "mammoth"; export const runtime = "nodejs"; export const maxDuration = 60; export async function POST(req: NextRequest) { const formData = await req.formData(); const file = formData.get("file") as File; if (!file) { return NextResponse.json({ error: "没有文件" }, { status: 400 }); } const buffer = Buffer.from(await file.arrayBuffer()); let text = ""; if (file.type === "application/pdf") { const result = await pdf(buffer); text = result.text; } else if ( file.type === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ) { const result = await mammoth.extractRawText({ buffer }); text = result.value; } else { return NextResponse.json({ error: "不支持的文件格式" }, { status: 400 }); } // 简单清洗:去掉多余空行 text = text.replace(/\n{3,}/g, "\n\n").trim(); return NextResponse.json({ text, charCount: text.length }); }maxDuration = 60是给 Vercel 部署用的,PDF 解析大文件可能超过默认的 10 秒。本地开发无所谓,但部署上去一定要加。
4.3 LangGraph 图的组装与流式输出
图组装是整个项目的核心,我把所有节点和边在一个文件里定义清楚:
// lib/agent/graph.ts import { StateGraph, END } from "@langchain/langgraph"; import { ResumeState } from "./state"; import { parseNode } from "./nodes/parse"; import { interviewNode } from "./nodes/interview"; import { optimizeNode } from "./nodes/optimize"; import { reviewNode } from "./nodes/review"; import { finalizeNode } from "./nodes/finalize"; const workflow = new StateGraph(ResumeState) .addNode("parse", parseNode) .addNode("interview", interviewNode) .addNode("optimize", optimizeNode) .addNode("review", reviewNode) .addNode("finalize", finalizeNode) .addEdge("__start__", "parse") .addConditionalEdges("parse", (state) => { return state.missingFields.length > 0 ? "interview" : "optimize"; }) .addEdge("interview", "parse") .addEdge("optimize", "review") .addConditionalEdges("review", (state) => { const lastMessage = state.messages[state.messages.length - 1]; const content = lastMessage.content as string; return content.includes("确认") ? "finalize" : "interview"; }) .addEdge("finalize", END); export const resumeAgent = workflow.compile();流式输出这块,LangGraph.js 提供了streamEvents方法,可以拿到每个节点的执行事件。我在 Route Handler 里把它转成 SSE 格式返回给前端:
// app/api/chat/route.ts export async function POST(req: NextRequest) { const { message, threadId } = await req.json(); const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { const eventStream = resumeAgent.streamEvents( { messages: [new HumanMessage(message)] }, { version: "v2", configurable: { thread_id: threadId } } ); for await (const event of eventStream) { if (event.event === "on_chat_model_stream") { const chunk = event.data.chunk?.content; if (chunk) { controller.enqueue( encoder.encode(`data: ${JSON.stringify({ text: chunk })}\n\n`) ); } } if (event.event === "on_chain_end" && event.name === "finalize") { controller.enqueue(encoder.encode(`data: [DONE]\n\n`)); } } controller.close(); }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", }, }); }前端消费的时候用fetch加 reader,逐块解析data:行。这里有个细节:SSE 的每条消息必须以\n\n结尾,少一个换行前端就收不到。我调试的时候因为这个问题浪费了不少时间。
4.4 状态持久化与多轮对话
多轮对话的关键是状态要能跨请求保持。LangGraph.js 提供了 Checkpointer 机制,我用的是MemorySaver做开发,生产环境换成了基于 Postgres 的持久化。
// lib/agent/graph.ts import { MemorySaver } from "@langchain/langgraph"; const checkpointer = new MemorySaver(); export const resumeAgent = workflow.compile({ checkpointer });调用的时候通过configurable.thread_id来区分不同用户的会话。同一个 thread_id 的多次调用会共享状态,这样用户刷新页面后对话还能继续。生产环境我用的是@langchain/langgraph-checkpoint-postgres,把状态存到数据库里,好处是服务重启不丢数据,而且可以做会话历史查询。
这里有个性能上的考量:状态里存了完整的messages数组,如果对话很长,每次读写都是全量。我的做法是在 messages 超过 30 条时触发一个压缩节点,把前 20 条总结成一段摘要,只保留最近 10 条原文。这样既保留了上下文,又控制了状态体积。
5. 并发处理与性能优化实录
5.1 AI Agent 怎么扛并发:从单机到队列
“AI Agent 怎么扛并发”是最近被问得最多的问题,我拿这个项目的实际数据来说。单机 Node 进程,每个请求要调用 3 到 5 次大模型 API,每次调用平均 2 到 4 秒。如果不做任何处理,并发 10 个请求就会明显卡顿,因为 Node 虽然是异步的,但大模型 API 的响应时间摆在那里,连接池和内存都会吃紧。
我的方案分三层。第一层是请求队列,用p-queue控制同时进行的 Agent 执行数量,超过的排队等待。第二层是模型调用缓存,对于相同的输入(比如同一份简历的解析),用哈希做 key 缓存结果,避免重复调用。第三层是流式输出,让用户感知到的等待时间大幅缩短,因为第一个 token 通常 1 秒内就回来了。
// lib/queue.ts import PQueue from "p-queue"; export const agentQueue = new PQueue({ concurrency: 5, // 同时最多 5 个 Agent 执行 interval: 1000, // 每秒最多 intervalCap: 10, // 10 个新任务 });concurrency: 5这个数字是压测出来的。我模拟了 50 个并发请求,分别测了 concurrency 为 3、5、10、20 的情况。结果是 5 的时候吞吐量和响应时间的平衡最好,10 以上开始出现 API 限流错误,3 则吞吐量上不去。当然这个数字跟你的模型供应商配额有关,需要自己压测调整。
5.2 缓存策略:哪些能缓存,哪些不能
缓存这块要非常小心,因为简历是敏感数据。我的原则是只缓存模型调用结果,不缓存用户数据本身,而且缓存 key 用输入内容的哈希,不存原始文本。
// lib/cache.ts import { createHash } from "crypto"; const cache = new Map<string, { value: any; expireAt: number }>(); export function cacheKey(input: string): string { return createHash("sha256").update(input).digest("hex"); } export async function withCache<T>( key: string, ttlMs: number, fn: () => Promise<T> ): Promise<T> { const cached = cache.get(key); if (cached && cached.expireAt > Date.now()) { return cached.value; } const value = await fn(); cache.set(key, { value, expireAt: Date.now() + ttlMs }); return value; }解析节点和 JD 分析节点适合缓存,因为同样的输入结果应该一致。但追问节点和优化节点不适合,因为它们依赖对话历史,每次输入都不同。TTL 我设的是 1 小时,太长了内存吃不消,太短了命中率低。
5.3 超时与重试:大模型调用不稳定怎么办
大模型 API 偶尔超时或返回 5xx 是常态,必须做重试。我用的是指数退避,第一次失败等 1 秒,第二次等 2 秒,第三次等 4 秒,最多重试 3 次。
// lib/retry.ts export async function withRetry<T>( fn: () => Promise<T>, maxRetries = 3, baseDelay = 1000 ): Promise<T> { let lastError: Error; for (let i = 0; i < maxRetries; i++) { try { return await fn(); } catch (err) { lastError = err as Error; // 4xx 错误不重试,因为重试也没用 if (lastError.message.includes("400")) throw lastError; await new Promise(r => setTimeout(r, baseDelay * Math.pow(2, i))); } } throw lastError!; }这里有个经验:4xx 错误不要重试。我一开始无脑重试所有错误,结果遇到 API key 无效的情况,白白等了 7 秒才报错。后来加了判断,4xx 直接抛,5xx 和网络错误才重试。
6. 常见问题与排查技巧实录
6.1 问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| PDF 解析报 ENOENT | pdf-parse 测试文件问题 | 看报错堆栈是否指向 pdf-parse | next.config.js 里配 canvas/encoding alias |
| 流式输出前端收不到 | SSE 格式不对 | 浏览器 Network 面板看响应 | 确保每条消息以\n\n结尾 |
| 状态跨请求丢失 | 没配 checkpointer | 检查 compile 参数 | 加 MemorySaver 或 Postgres checkpointer |
| 模型输出 JSON 解析失败 | 没用 structuredOutput | 打印原始输出 | 改用 withStructuredOutput + Zod |
| 并发高时 API 限流 | 没有队列控制 | 看 API 返回 429 | 加 p-queue 控制并发数 |
| 对话历史 token 爆了 | messages 无限增长 | 看单次请求 token 数 | 加压缩节点,保留最近 N 条 |
| Edge Runtime 报错 | LangGraph 不支持 | 看部署环境 | Route Handler 声明 nodejs runtime |
6.2 几个我踩过的深坑
第一个坑是消息 reducer 写错导致历史丢失。我一开始把messages的 reducer 写成了(_, update) => update,结果每次节点返回新消息都会覆盖历史,对话到第二轮就失忆了。正确的写法是(current, update) => current.concat(update)。这个 bug 很隐蔽,因为单轮测试完全正常,只有多轮才暴露。
第二个坑是条件边的返回值必须是节点名。我一开始在条件函数里返回了true或false,想着用布尔值控制分支,结果 LangGraph 直接报错说找不到名为true的节点。条件边必须返回字符串,对应addNode时注册的节点名。
第三个坑是流式输出和结构化输出不能同时用。withStructuredOutput返回的是完整对象,不支持流式。所以 parseNode 和 optimizeNode 里的 JD 分析用的是非流式,只有 interviewNode 和 optimizeNode 的最终输出用流式。这个限制在 LangChain 文档里没明说,是我试出来的。
6.3 独家避坑技巧
关于简历解析的准确率,我有个小技巧:在 Prompt 里给一两个 few-shot 示例。不用多,一两个就够,模型对字段格式的理解会准确很多。我加了一个示例之后,achievements字段的提取准确率从大概 70% 提到了 90% 以上。
关于追问的自然度,我的经验是在系统 Prompt 里明确禁止某些句式。比如“请提供”“请填写”“请补充”这类词,一旦出现就很不像人。我直接在 Prompt 里写“不要使用‘请提供’‘请填写’这类词”,效果立竿见影。
关于成本控制,gpt-4o-mini 在简历场景完全够用。我对比过 gpt-4o 和 mini 的输出质量,在结构化提取和改写这两个任务上差距很小,但成本差了十几倍。只有最终的润色环节我用了稍好的模型,因为那个环节用户对文字质量最敏感。
7. 后续可以怎么扩展
这个项目目前跑通的是核心流程,但还有不少可以深挖的方向。我最近在试的是多简历版本管理,就是同一个用户针对不同岗位生成不同版本的简历,用 LangGraph 的 subgraph 来做隔离。另外简历评分也是个有意思的方向,用一套规则加模型打分,给用户一个直观的优化前后对比。
还有一个我比较看好的扩展是接入真实岗位数据。现在用户要手动粘贴 JD,如果能对接招聘平台的公开岗位信息,用户输入目标岗位名称就能自动拉取相关 JD 做匹配,体验会好很多。不过这块涉及数据合规,要谨慎处理。
最后分享一个我在实际使用中的体会:AI Agent 的价值不在于模型多强,而在于流程设计得多顺。同样的模型,流程设计得好,用户感觉像有个专业顾问在帮忙;流程设计得差,就像在跟一个答非所问的机器人较劲。LangGraph.js 给我的最大帮助,就是让我能把精力放在流程设计上,而不是纠结于状态怎么传递、分支怎么控制这些底层细节。