【自学AI】08 LangChain.js 实战:用 30 行代码让 AI 分析一份简历
上一篇,我们搞清楚了 LangChain 的四个核心组件—— Language Models、Prompt Templates、Chains、Output Parsers。
概念讲完了,该上代码了。
这篇我们做一个真实的功能:根据候选人简历,生成一份初步的分析报告。 代码不多,但麻雀虽小,五脏俱全——所有的核心用法都在里面。
有一个时刻,是每个做 AI 开发的人都会记住的——
第一次调通接口,终端里打出来那段 JSON, 你盯着屏幕,意识到:这不是我写的逻辑,但它真的在工作。
这篇文章,就是为了帮你到达那个时刻。
功能拆解:5 步完成简历分析
写代码之前,先把整个流程想清楚。
想清楚再动手,不是在浪费时间—— 是在省时间。 没想清楚就写,代码写到一半才发现方向不对,才是真的浪费。
1. 用户调用接口,传入简历文本和岗位要求2. Controller 接收请求,调用 Service3. Service 通过 AIModelFactory 获取模型4. 用 PromptTemplate 填充简历内容5. 链调用 AI,返回结构化的分析报告就这 5 步。接下来一步一步实现。
Step 1:定义 Prompt
创建文件 src/interview/prompts/resume-quiz.prompts.ts:
export const RESUME_QUIZ_PROMPT = `你是一个资深的人力资源专家,有 15 年的招聘经验。你能快速从简历中识别候选人的核心能力。
## 任务
分析以下简历,提取关键信息,给出初步评估。
## 简历内容
{resume_content}
## 岗位要求
{job_description}
## 分析要求
1. 提取候选人的: - 工作年限 - 主要技能 - 最近工作经历 - 教育背景
2. 评估匹配度(0-100)
3. 识别优势和不足
## 输出格式(JSON)
{{ "years_of_experience": 数字, "skills": ["技能1", "技能2"], "recent_position": "最近的职位", "education": "学历", "match_score": 数字(0-100), "strengths": ["优势1", "优势2"], "gaps": ["缺陷1", "缺陷2"], "summary": "1-2 句总结"}}`;Prompt 是 AI 应用的第一道门。 门开得好,后面的事情才顺。 门开得不好,模型再强也救不了。
⚠️ 注意双花括号:输出格式里用的是
{{}}而不是{}。 原因是PromptTemplate会把单个{变量名}当成变量占位符来解析。 如果你想在 Prompt 里输出字面量的花括号,需要用{{}}来转义,渲染后会变成普通的{}。
Step 2:安装依赖
pnpm add langchain@1.1.1 @langchain/community@1.0.5 @langchain/core@1.1.0 @langchain/deepseek@1.0.2 @langchain/openai@1.1.3版本锁死,别用 latest。 LangChain 迭代很快,版本不对容易出奇怪的问题。 踩过一次就知道了——排查半天,最后发现是版本不兼容。
Step 3:创建 AIModelFactory
这是这篇文章里最重要的设计决策,单独拿出来说。
为什么要单独抽一个 Factory?
你可能会问:直接在 InterviewService 里 new ChatDeepSeek(...) 不行吗?
可以,但会有问题。
想象一下,你的项目里有 InterviewService、QuizService、AssessmentService,
三个 Service 都需要调用 AI。
如果每个 Service 都自己初始化模型,就会出现这样的代码:
// ❌ 三个 Service,三段一样的初始化代码// InterviewServicethis.model = new ChatDeepSeek({ apiKey: ..., model: ..., temperature: ... });
// QuizServicethis.model = new ChatDeepSeek({ apiKey: ..., model: ..., temperature: ... });
// AssessmentServicethis.model = new ChatDeepSeek({ apiKey: ..., model: ..., temperature: ... });某天你想从 DeepSeek 换成 OpenAI,你得改三个地方。 某天 API Key 的读取方式变了,你得改三个地方。 某天你想统一加一个日志,你还得改三个地方。
改一个地方,影响三个地方——这不是 bug,这是设计问题。 设计问题比 bug 更难发现,也更难修。
把模型初始化逻辑提取到 AIModelFactory,所有 Service 都从这里获取模型。
换模型只改一个文件,其他地方完全不动。
这是单一职责原则最直接的体现。
代码实现
创建 src/ai/services/ai-model.factory.ts:
import { Injectable, Logger } from '@nestjs/common';import { ConfigService } from '@nestjs/config';import { ChatDeepSeek } from '@langchain/deepseek';
@Injectable()export class AIModelFactory { private readonly logger = new Logger(AIModelFactory.name);
constructor(private configService: ConfigService) {}
/** * 创建默认模型(内容生成场景) * temperature 0.7,平衡创意和稳定性 * 适合:生成面试题、生成分析报告 */ createDefaultModel(): ChatDeepSeek { const apiKey = this.configService.get<string>('DEEPSEEK_API_KEY');
if (!apiKey) { this.logger.warn('DEEPSEEK_API_KEY 未配置'); }
return new ChatDeepSeek({ apiKey: apiKey || 'dummy-key', model: this.configService.get<string>('DEEPSEEK_MODEL') || 'deepseek-chat', temperature: Number(this.configService.get<string>('DEEPSEEK_TEMPERATURE')) || 0.7, maxTokens: Number(this.configService.get<string>('DEEPSEEK_MAX_TOKENS')) || 4000, }); }
/** * 创建稳定模型(评估打分场景) * temperature 0.3,输出更一致 * 适合:评估候选人答案、打分 */ createStableModel(): ChatDeepSeek { return new ChatDeepSeek({ apiKey: this.configService.get<string>('DEEPSEEK_API_KEY') || 'dummy-key', model: this.configService.get<string>('DEEPSEEK_MODEL') || 'deepseek-chat', temperature: 0.3, maxTokens: 2000, }); }}两个方法,对应两种场景:
| 方法 | temperature | 适用场景 |
|---|---|---|
createDefaultModel() | 0.7 | 生成题目、生成报告——需要一些创意 |
createStableModel() | 0.3 | 评估答案、打分——需要稳定一致 |
temperature不只是一个数字参数—— 它决定了 AI 的”性格”。 生成题目要有创意,评估答案要公正稳定。 同一个模型,不同的任务,用不同的性格。
Step 4:创建 AIModule
创建 src/ai/ai.module.ts:
import { Module } from '@nestjs/common';import { AIModelFactory } from './services/ai-model.factory';
@Module({ providers: [AIModelFactory], exports: [AIModelFactory], // 导出,其他模块才能用})export class AIModule {}然后在 src/interview/interview.module.ts 里导入它:
import { AIModule } from '../ai/ai.module';
@Module({ imports: [ ConfigModule, AIModule, // 导入 AI 模块 ], // ...})export class InterviewModule {}Step 5:在 InterviewService 里组装链
创建 src/interview/services/interview.service.ts:
import { PromptTemplate } from '@langchain/core/prompts';import { JsonOutputParser } from '@langchain/core/output_parsers';import { Injectable, Logger } from '@nestjs/common';import { AIModelFactory } from '../../ai/services/ai-model.factory';import { RESUME_QUIZ_PROMPT } from '../prompts/resume-quiz.prompts';
@Injectable()export class InterviewService { private readonly logger = new Logger(InterviewService.name);
constructor( private aiModelFactory: AIModelFactory, // 注入工厂,不自己初始化模型 ) {}
async analyzeResume(resumeContent: string, jobDescription: string) { // 1. Prompt 模板 const prompt = PromptTemplate.fromTemplate(RESUME_QUIZ_PROMPT);
// 2. 从工厂获取模型(不自己 new) const model = this.aiModelFactory.createDefaultModel();
// 3. 输出解析器 const parser = new JsonOutputParser();
// 4. 组装链 const chain = prompt.pipe(model).pipe(parser);
// 5. 调用 try { this.logger.log('开始分析简历...');
const result = await chain.invoke({ resume_content: resumeContent, job_description: jobDescription, });
this.logger.log('简历分析完成'); return result; } catch (error) { this.logger.error('简历分析失败:', error); throw error; } }}注意 InterviewService 里没有任何模型初始化的代码——
它只关心业务逻辑,模型的事情交给 AIModelFactory。
这就是关注点分离: 每一层只知道自己该知道的事。
Service不需要知道用的是 DeepSeek 还是 Claude, 它只需要知道”给我一个能用的模型”。 细节藏在Factory里,Service保持干净。
Step 6:Controller 暴露接口
在 src/interview/interview.controller.ts 里加一个接口:
@Post('/analyze-resume')async analyzeResume( @Body() body: { resume: string; jobDescription: string },) { const result = await this.interviewService.analyzeResume( body.resume, body.jobDescription, );
return { code: 200, data: result, };}Step 7:配置环境变量
在项目根目录创建 .env.development:
# AI 配置DEEPSEEK_API_KEY=your-api-key-hereDEEPSEEK_MODEL=deepseek-chatDEEPSEEK_TEMPERATURE=0.7DEEPSEEK_MAX_TOKENS=4000API Key 去 DeepSeek 开放平台 申请,充值 1 元够课程学习用了。
测试接口
启动项目:
pnpm run start:dev看到 Nest application successfully started 就可以测试了。
方式 1:直接用 curl
curl -X POST http://localhost:3000/interview/analyze-resume \ -H "Content-Type: application/json" \ -d '{ "resume": "姓名:张三\n工作年限:5年\n技术栈:Java, Spring Boot, MySQL, Redis\n最近工作:高级后端开发\n主要项目:电商系统、支付系统\n教育背景:计算机本科,985高校", "jobDescription": "职位:Java 后端开发\n工作年限:3-5年\n技能要求:Java, Spring Boot, MySQL, Redis\n岗位职责:设计高并发系统" }'方式 2:先保存 JSON 文件,再调用(更清晰)
创建 test-resume.json:
{ "resume": "姓名:张三\n工作年限:5年\n技术栈:Java, Spring Boot, MySQL, Redis\n最近工作:高级后端开发\n主要项目:电商系统、支付系统\n教育背景:计算机本科,985高校", "jobDescription": "职位:Java 后端开发\n工作年限:3-5年\n技能要求:Java, Spring Boot, MySQL, Redis\n岗位职责:设计高并发系统"}curl -X POST http://localhost:3000/interview/analyze-resume \ -H "Content-Type: application/json" \ -d @test-resume.json
正常的话,你会得到这样的结果:
{ "code": 200, "data": { "years_of_experience": 5, "skills": ["Java", "Spring Boot", "MySQL", "Redis"], "recent_position": "高级后端开发", "education": "计算机本科,985高校", "match_score": 88, "strengths": ["技术栈高度匹配", "有大型项目经验", "学历背景优秀"], "gaps": ["未提及消息队列经验", "缺少架构设计经历"], "summary": "候选人技术栈与岗位高度匹配,5年经验符合要求,建议进入技术面试环节。" }}如果出错了,对照这张表排查:
| 报错信息 | 原因 | 解决方法 |
|---|---|---|
DEEPSEEK_API_KEY is not set | 环境变量没配置 | 检查 .env.development 文件 |
connect ECONNREFUSED | 项目没启动 | 运行 pnpm run start:dev |
404 Not Found | 路由地址或方法不对 | 确认是 POST 请求,路径是 /interview/analyze-resume |
| 请求超时 | DeepSeek API 响应慢 | 正常现象,通常 10-30 秒,耐心等待 |
深入理解:两个关键概念
代码跑通了,再深入理解两个概念。
Invoke vs Stream:什么时候用哪个?
LangChain 的链有两种调用方式:
invoke:等待完整结果
const result = await chain.invoke({ ... });// 等待 10-30 秒...console.log(result); // 一次性得到完整的 JSON 对象stream:实时流式返回
const stream = await chain.stream({ ... });for await (const chunk of stream) { console.log(chunk); // 一点一点收到,像打字机一样}在我们的面试系统里,两种方式都会用到:
| 功能 | 调用方式 | 原因 |
|---|---|---|
| 简历押题 | invoke | 需要完整的 JSON 数据,解析后存入数据库 |
| 专项面试对话 | stream | 实时显示 AI 的回复,体验更好 |
| HR 行测评估 | stream | 让用户感觉 AI 在”思考”,而不是等待黑屏 |
选择
invoke还是stream,本质上是在问: 用户需要等待结果,还是需要感受过程? 数据入库,等结果;对话交互,要过程。 搞清楚这个,选哪个就很自然了。
流式输出的具体实现,我们会在后面的章节详细讲。
Pipe 的本质:面向接口编程
pipe 看起来只是把组件串联起来,但背后有一个更重要的思想:
只要实现了相同的接口,任何组件都可以插进链里。
// 标准的三段链const chain = prompt.pipe(model).pipe(parser);
// 想在中间加日志?插进去就行const chain = prompt .pipe(model) .pipe(new MyLoggingMiddleware()) // 自定义组件 .pipe(parser);
// 想在最后加一个数据校验?const chain = prompt .pipe(model) .pipe(parser) .pipe(new MyValidator()); // 再插一个这就是为什么 LangChain 的链可以无限扩展—— 你可以随时插入新的组件,而不需要修改已有的代码。
软件工程里有一条原则叫开闭原则: 对扩展开放,对修改关闭。
pipe就是这个原则最直观的实现。 加功能,插组件;不改已有的代码,不引入新的风险。
常见陷阱
学到这里,有几个坑提前说一下。
陷阱 1:变量替换用 {} 不是 ${}
// ❌ 错的——这是 JavaScript 字符串模板语法,不是 LangChain 变量const prompt = PromptTemplate.fromTemplate(`候选人:${name}`);
// ✅ 对的——LangChain 用单花括号const prompt = PromptTemplate.fromTemplate(`候选人:{name}`);为什么容易踩?因为你的手指已经习惯了写
${}。 肌肉记忆比脑子快。 遇到 Prompt 变量不生效,第一件事就是检查这里。
陷阱 2:JSON 解析失败
AI 有时候会生成格式不标准的 JSON(多逗号、少引号)。 加上 try-catch,失败了就重试:
try { return await chain.invoke({ ... });} catch (error) { this.logger.error('解析失败,准备重试:', error); // 重试逻辑,或者返回降级结果 throw error;}AI 不是确定性程序,它的输出有概率出错。 不加 try-catch,一次 AI 抽风,整个请求就崩了。 防御性编程,在 AI 应用里不是可选项,是必选项。
陷阱 3:改了 temperature 没效果
改完 temperature 之后,要重新创建模型对象,不能复用旧的实例。
AIModelFactory 每次调用都会创建新实例,这个问题不会出现。
这就是 Factory 模式的另一个价值—— 每次调用都是新实例,参数一定是最新的,不会有状态残留的问题。
陷阱 4:流式输出中途停止
通常是 maxTokens 设置太小,或者网络超时。
检查 maxTokens 的值,确保足够大。
AI 的输出是按 token 计费的,
maxTokens是你设的上限。 上限太小,AI 说到一半就被截断了,返回的 JSON 不完整,解析必然失败。 宁可设大一点,不要设小了。
小结
这篇文章我们完成了一个完整的 AI 功能:
Prompt 模板(resume-quiz.prompts.ts) ↓AIModelFactory(统一管理模型初始化) ↓InterviewService(组装链,调用 AI) ↓Controller(暴露接口) ↓用户拿到结构化的简历分析报告每一层只做自己的事:
- 换模型,只改
AIModelFactory - 改 Prompt,只改
prompts文件 - 改业务逻辑,只改
Service
改动不扩散,就是好架构。
但现在有一个问题:AI 没有记忆。
用户上传简历,AI 分析完了。 用户再问一句”我的简历有什么可以优化的地方?”—— AI 不记得刚才分析了什么,只能从头开始。
这在对话类功能里是致命的。
没有记忆的 AI,就像每次见面都不认识你的朋友。 能用,但用不好。
下一篇,我们来解决这个问题: 如何管理 AI 的对话历史,让它记住上下文。
昇哥 · 2026年3月 全栈开发 × AI 学习途中,把踩过的坑写下来
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!