【自学AI】08 LangChain.js 实战:用 30 行代码让 AI 分析一份简历

3277 字
16 分钟
【自学AI】08 LangChain.js 实战:用 30 行代码让 AI 分析一份简历

上一篇,我们搞清楚了 LangChain 的四个核心组件—— Language Models、Prompt Templates、Chains、Output Parsers。

概念讲完了,该上代码了。

这篇我们做一个真实的功能:根据候选人简历,生成一份初步的分析报告。 代码不多,但麻雀虽小,五脏俱全——所有的核心用法都在里面。


有一个时刻,是每个做 AI 开发的人都会记住的——

第一次调通接口,终端里打出来那段 JSON, 你盯着屏幕,意识到:这不是我写的逻辑,但它真的在工作。

这篇文章,就是为了帮你到达那个时刻。


功能拆解:5 步完成简历分析#

写代码之前,先把整个流程想清楚。

想清楚再动手,不是在浪费时间—— 是在省时间。 没想清楚就写,代码写到一半才发现方向不对,才是真的浪费。

1. 用户调用接口,传入简历文本和岗位要求
2. Controller 接收请求,调用 Service
3. 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:安装依赖#

Terminal window
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?#

你可能会问:直接在 InterviewServicenew ChatDeepSeek(...) 不行吗?

可以,但会有问题。

想象一下,你的项目里有 InterviewServiceQuizServiceAssessmentService, 三个 Service 都需要调用 AI。 如果每个 Service 都自己初始化模型,就会出现这样的代码:

// ❌ 三个 Service,三段一样的初始化代码
// InterviewService
this.model = new ChatDeepSeek({ apiKey: ..., model: ..., temperature: ... });
// QuizService
this.model = new ChatDeepSeek({ apiKey: ..., model: ..., temperature: ... });
// AssessmentService
this.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

Terminal window
# AI 配置
DEEPSEEK_API_KEY=your-api-key-here
DEEPSEEK_MODEL=deepseek-chat
DEEPSEEK_TEMPERATURE=0.7
DEEPSEEK_MAX_TOKENS=4000

API Key 去 DeepSeek 开放平台 申请,充值 1 元够课程学习用了。


测试接口#

启动项目:

Terminal window
pnpm run start:dev

看到 Nest application successfully started 就可以测试了。

方式 1:直接用 curl

Terminal window
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岗位职责:设计高并发系统"
}
Terminal window
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 学习途中,把踩过的坑写下来

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

【自学AI】08 LangChain.js 实战:用 30 行代码让 AI 分析一份简历
https://blog.fridolph.top/posts/2026-02-15__ai-dev/
作者
Fridolph
发布于
2026-02-15
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Fridolph
热爱 Coding、音乐和羽毛球的 90 后全栈工程师
公告
欢迎访问我的小站 ^_^ 我是昇哥,热爱Coding,喜爱音乐、羽毛球和摄影的 90后全栈工程师
分类
标签

文章目录