学完 LangChain 第一阶段之后,如果你脑子里留下的只是 ChatModel、PromptTemplate、OutputParser、tool、memory、RAG、LCEL 这些 API 名字,其实还不算真正学会。
因为工程里最难的,从来不是“记住有哪些类”,而是搞清楚:
- 这些组件分别解决什么问题
- 它们在整条 AI 应用链路里处于什么位置
- 为什么有些能力必须要上,有些能力却不该过早引入
- 一个能跑通的 demo,怎么变成一个能维护的 Agent 系统
如果把这一阶段的内容压缩成一句话,我的判断是:
AI Agent 开发,本质上不是“调用一次模型”,而是在管理一条可控的数据流:怎么喂给模型、怎么约束模型、怎么让模型调用外部能力、怎么保留上下文、怎么接入知识、怎么把这一切编排成稳定流程。
LangChain 的价值,也正在这里。
它不是单纯的“大模型 SDK 集合”,更像是一层 AI 应用运行时:上层面向业务,下层适配模型、工具、向量库和执行链路。
这篇文章不打算按 API 清单来回顾,而是从工程视角给出我对 LangChain 第一阶段的 6 个核心判断。你只要把这 6 件事想明白,后面继续学 LangGraph、LangSmith、MCP、RAG 优化,都会更顺。
判断一:LangChain 首先解决的,不是“更方便调模型”,而是“避免业务代码和模型供应商耦死”
很多人一开始会觉得,直接调 OpenAI、Claude、Gemini 的官方 SDK 不就行了,为什么还要加一层 LangChain?
如果你只是做一次性的 demo,这个问题成立。
但只要进入真实项目,它很快就不成立。
原因很简单,不同大模型提供商虽然都叫“聊天模型”,但接口细节并不统一。
比如系统提示词的位置就不一样:
- OpenAI 风格:放在
messages里 - Anthropic 风格:有单独的
system字段 - Gemini 风格:又是
system_instruction
这还只是最表面的差异。
更实际的差异还包括:
- 流式返回格式不同
- 工具调用字段不同
- 多模态输入组织方式不同
- 结构化输出支持能力不同
- 各家模型的特有参数不同
如果业务代码直接绑定某一家接口,后果通常有两个:
- 切模型会越来越痛
- 功能一旦做深,代码里到处都是供应商分支逻辑
LangChain 在这里做的第一件事,就是把不同模型适配成统一的 BaseChatModel 抽象。
也就是说,你真正依赖的不是某一家模型的原生请求格式,而是 LangChain 统一后的调用语义。
这件事的工程价值非常大。
一个最朴素的例子
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
const result = await model.invoke("解释一下什么是 RAG");
console.log(result.content);
这段代码本身很普通,但它的意义不在“成功调到了一个模型”,而在于你的业务侧只面向统一的 ChatModel 调用方式。
这里最容易误解的一点
很多国产模型兼容 OpenAI 协议,所以用 ChatOpenAI 也能调,比如 Qwen、DeepSeek 的某些服务都支持这种方式。
但这不代表“有兼容协议就不需要专用适配类”。
原因是:
- 兼容 OpenAI 协议,解决的是“能不能调”
- 专用 ChatModel,解决的是“能不能把这家模型的能力用全”
如果你只是跑基础文本对话,用兼容接口没问题。
如果你需要充分利用某个模型的专有特性,比如更细粒度的工具调用、多模态、特殊采样参数,那专用适配类通常更稳。
所以我的建议很明确:
- 快速验证阶段:优先统一走 LangChain ChatModel 抽象
- 真正选型落地阶段:能用专用 ChatModel 就尽量用专用适配
这不是“写法偏好”,而是为了给模型切换和能力扩展留下余地。
判断二:Prompt 工程的本质不是写文案,而是管理模型上下文
很多团队一开始做 AI 功能,最先做的是拼字符串:
const prompt = `
你是企业知识库助手。
用户问题:
${question}
检索资料:
${docs}
历史对话:
${history}
`;
这段代码能跑,但只适合 demo。
因为真实项目里,Prompt 不是一段静态文本,而是一层持续演化的上下文组织系统。它至少会同时承载这些信息:
- 角色设定
- 业务规则
- 当前任务
- 用户输入
- 历史对话
- 检索到的上下文
- Few-shot 示例
- 输出格式约束
如果这些东西全都混在一个大字符串里,早晚会出事:
- 角色和规则改动时不敢动
- 历史消息和检索片段互相污染
- token 被谁吃掉了根本看不出来
- 一个 Prompt 改好了,另一个场景还在复制旧版本
这也是为什么我更推荐把 Prompt 看成“上下文组装层”,而不是“提示词文本”。
ChatPromptTemplate 解决的核心问题
如果你用的是聊天模型,默认就应该优先考虑 ChatPromptTemplate。
import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts";
const qaPrompt = ChatPromptTemplate.fromMessages([
[
"system",
`你是企业内部知识库助手。
1. 优先依据资料回答
2. 资料不足时明确说明不知道
3. 不要编造流程细节`,
],
new MessagesPlaceholder("history"),
[
"human",
`问题:{question}
资料:
{context}`,
],
]);
这段代码的价值不是“写法更优雅”,而是上下文来源被明确分层了:
system放角色和高优先级规则history放会话记忆human放当前问题和动态上下文
这让 Prompt 从“大字符串”变成了“可维护结构”。
什么时候要用 PipelinePromptTemplate
一旦 Prompt 开始模块化,比如你要把下面这些块拆开复用:
- 企业角色设定
- 部门级规则
- 输出格式要求
- 任务描述
这时 PipelinePromptTemplate 就有价值了。
它不是为了让模型“更聪明”,而是为了让 Prompt 资产可以像代码一样复用。
Few-shot 什么时候该上,什么时候别乱上
Few-shot 很常见,但也最容易被滥用。
适合 Few-shot 的场景:
- 输出格式有微妙约束
- 需要模型学习风格或边界
- 同一类任务存在稳定范式
不适合滥加 Few-shot 的场景:
- 检索上下文已经很长
- 示例和当前任务相似度不高
- 你只是想“多塞点东西试试”
因为 Few-shot 的本质是用上下文换模型行为一致性。
它不是免费的,代价就是上下文窗口和推理成本。
所以我的判断是:
Prompt 工程里真正重要的,不是句子写得多华丽,而是把哪些信息该进 Prompt、以什么结构进 Prompt、优先级如何排序,这些事情管清楚。
判断三:输出控制不只是“解析 JSON”,而是在给模型返回结果建立契约
AI 应用一旦进入业务系统,模型输出就不能一直停留在“自然语言挺像那么回事”的阶段。
因为你最终很可能要把结果继续交给:
- 前端表单
- 数据库存储
- 工作流系统
- 下游工具调用
- 审批或风控规则
这时“能看懂”不够,必须“结构稳定”。
LangChain 第一阶段里,输出控制这块非常关键。
我认为最值得建立的认知是:
结构化输出解决的,不是解析方便,而是把模型输出变成系统契约。
首选方案通常不是手写 JSON prompt,而是 withStructuredOutput
如果模型支持 tool calling 或 JSON schema,优先用模型原生能力约束输出。
import { z } from "zod";
import { ChatOpenAI } from "@langchain/openai";
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
const ticketExtractor = model.withStructuredOutput(
z.object({
title: z.string().describe("工单标题"),
category: z.enum(["bug", "feature", "question"]).describe("工单类型"),
priority: z.enum(["low", "medium", "high"]).describe("优先级"),
})
);
const result = await ticketExtractor.invoke(
"用户反馈订单页面在手机端无法滚动,影响下单,优先处理。"
);
这样做的默认好处是:
- 结构更稳定
- 少依赖 Prompt 约束
- 对接业务系统时更可靠
那 OutputParser 还有没有价值
有,而且在两个场景下很重要:
1. 模型不支持原生结构化输出
这时你就要退回到“Prompt 里写格式要求 + Parser 解析”的方案。
2. 你需要流式消费结构化片段
这在 Agent 场景里尤其常见。
比如流式工具调用时,参数往往会被拆成很多碎片返回,自己手动拼 JSON 很麻烦,这时像 JsonOutputToolsParser 这样的工具就很有价值。
几类 Parser 的职责差异,最好从“适用场景”来理解
StringOutputParser:你只要最终文本StructuredOutputParser:你要一个稳定 JSON 结构,但模型不一定原生支持XMLOutputParser:输出必须是 XML 之类的指定格式JsonOutputToolsParser:重点不是一般文本,而是 tool call 的结构化片段,尤其适合流式
一个常见误区
很多人分不清“结构化输出”和“工具调用”的边界。
这两个虽然都可能产生结构化 JSON,但不是一回事:
- 结构化输出:目标是“稳定返回数据”
- 工具调用:目标是“让模型发起一个动作”
如果你的需求只是“把用户输入抽成对象”,优先用结构化输出。
如果你的需求是“让模型决定要不要触发外部能力”,那才是 tool call。
这是一个很典型的选型边界。
判断四:Tool 和 MCP 的本质,不是“给模型加插件”,而是把模型从纯生成升级成可执行系统
很多人学到 tool call 时会非常兴奋,因为它看起来像是大模型“终于可以干活了”。
这个感觉没错,但如果要说得更准确一点,我会这样定义:
Tool 机制的本质,是把模型从“文本生成器”升级成“动作决策器”。
模型不再只是返回一段答案,而是可以输出一个结构化动作意图:
- 调哪个工具
- 传什么参数
- 工具结果返回后再怎么继续推理
这就是 Agent 的起点。
最小可用的 Tool 调用链路
import { z } from "zod";
import { tool } from "@langchain/core/tools";
const searchPolicy = tool(
async ({ keyword }) => {
return `检索结果:关于「${keyword}」的制度文档共 3 篇。`;
},
{
name: "search_policy",
description: "检索企业制度文档",
schema: z.object({
keyword: z.string().describe("检索关键词"),
}),
}
);
const modelWithTools = model.bindTools([searchPolicy]);
这时模型就具备了调用 search_policy 的能力。
但真正重要的不是 bindTools 这一步,而是后面的执行闭环:
- 模型返回
tool_calls - 系统执行工具
- 把结果封装成
ToolMessage - 再喂回模型继续推理
- 直到模型不再发起新调用
也就是说,tool call 不是“一次函数调用”,而是一个闭环过程。
为什么 Tool 描述和参数 schema 这么重要
因为模型并不真正理解你的函数实现,它只看两样东西:
- 工具描述
- 参数定义
所以很多 tool 调不准,不是模型太笨,而是工具契约写得不清楚。
一个很实用的经验是:
name要明确,不要太抽象description要写清楚何时该用、不该用- schema 字段描述要站在模型视角写,而不是站在开发者视角写
MCP 为什么重要
自己定义 tool 解决的是“我手里已有的本地能力”。
MCP 解决的是“别人已经实现好的能力,我能不能标准化接进来”。
这件事对 Agent 生态特别关键。
因为现实里很多高价值能力并不是你自己写的,而是已经由:
- 地图服务
- 浏览器控制
- 文件系统
- 数据平台
- IDE / 编辑器
这些外部系统提供出来。
MCP 相当于给“外部可调用能力”建立了一套统一接入方式。
在 LangChain 里,@langchain/mcp-adapters 则把这层能力转成了可以被模型绑定和调用的工具集合。
Tool 和 MCP 的边界怎么判断
我的建议很简单:
- 你自己业务内的小能力:直接定义 tool
- 想复用标准化外部服务:优先接 MCP
Tool 是局部能力封装。
MCP 是跨进程、跨系统、可复用的能力接口。
它们不是互斥,而是同一层动作系统的两种接入方式。
判断五:Memory 和 RAG 解决的都不是“多存一点上下文”,而是控制模型看到什么
很多初学者容易把 memory 和 RAG 理解成“给模型加更多内容”。
这个理解太粗了。
它们真正解决的问题是:
当上下文有限时,哪些信息应该被带进这一轮推理。
这是一个上下文选择问题,而不是简单堆料问题。
为什么不能一直把所有消息都放进 messages
因为上下文窗口不是无限的。
对话一长,就会遇到这些问题:
- token 成本快速升高
- 旧信息淹没新信息
- 有效信息比例下降
- 最终直接超出模型限制
这也是为什么 Claude Code、Cursor 这类产品一旦上下文变长,就会开始做总结、压缩或选择性保留。
Memory 的 3 种策略,本质是 3 种上下文保留策略
1. 截断
最简单,保留最近几轮,丢掉更早的。
适合:
- 临时会话
- 近因信息更重要
- 低成本场景
不适合:
- 需要长期记住用户偏好
- 老信息对当前问题仍然重要
2. 总结
把旧消息压缩成摘要,再带进上下文。
适合:
- 长对话但细节不需要全部保留
- 任务进展需要抽象状态而不是逐句回放
代价是:
- 总结本身也要消耗模型调用
- 信息一旦被总结,就会有损
3. 检索
把历史对话向量化,在当前 query 来时再召回相关部分。
这才是真正更接近“长时记忆”的方案。
因为它不是把所有历史都塞进来,而是按语义相关性选。
RAG 也是同一个问题,只不过对象从“历史消息”变成了“外部知识”
RAG 的核心流程可以压缩成两段:
- 离线准备知识
- 在线按 query 检索相关片段再回答
离线阶段通常包含:
- Loader:从 PDF、网页、数据库、文件等加载内容
- Splitter:把长文切成更适合向量化和召回的小块
- Embedding:生成向量
- 向量数据库入库
在线阶段通常包含:
- 用户问题向量化
- 相似度检索
- 取回 Top K 片段
- 拼进 Prompt
- 让模型基于上下文回答
为什么 RAG 的关键不只是“向量库接上了”
因为实际效果受很多前置因素影响:
- 文档切分是否合理
- 块大小是否合适
- overlap 是否足够
- 元数据是否保留
- 检索返回几条最合适
- Prompt 是否清楚约束“只能基于资料回答”
比如 k 就不是越大越好。
- 太小:容易漏召回
- 太大:噪音片段太多,模型反而答偏
再比如 chunkSize 也没有绝对标准。
- 太小:语义断裂
- 太大:召回精度下降,还更占上下文
为什么长时记忆和 RAG 最终会靠向量数据库
因为无论对象是:
- 企业知识库文档
- 用户历史对话
- 会话摘要
- 外部数据片段
本质上都在回答同一个问题:
当前这个 query,最相关的上下文到底是什么?
只要问题是“按语义相关性挑上下文”,向量检索就是非常自然的手段。
所以我对 memory 和 RAG 的统一理解是:
- memory:从“历史交互”里挑上下文
- RAG:从“外部知识”里挑上下文
它们不是两门完全不同的技术,而是同一类上下文选择问题在不同数据源上的应用。
判断六:真正把 LangChain 变成工程框架的,不是组件本身,而是 LCEL
如果只学前面的组件,LangChain 更像一套 AI 开发工具箱:
- 这里一个 ChatModel
- 那里一个 PromptTemplate
- 再加个 OutputParser
- 需要时绑个 Tool
这样当然能写功能,但一旦系统复杂,问题就来了:
- 每个人组合方式都不一样
- 调用顺序散在业务代码里
- 想看某一步输入输出很麻烦
- 想统一加重试、回调、埋点也很别扭
这就是为什么我认为 LCEL 是第一阶段里真正的分水岭。
学会组件,你会用 LangChain。
学会 LCEL,你才开始真正按工程方式写 LangChain。
LCEL 到底在做什么
LCEL 的底层是 Runnable 抽象。
很多组件都实现了 Runnable:
- ChatModel
- PromptTemplate
- OutputParser
- 自定义函数
- 组合后的 chain
一旦它们都能被当成 Runnable,你就可以用统一方式把它们编排起来。
一条最简单的链
import { RunnableSequence } from "@langchain/core/runnables";
const chain = RunnableSequence.from([
prompt,
model,
parser,
]);
const result = await chain.invoke(input);
这看起来像是“少写了几行代码”,但它真正改变的是程序结构:
- 步骤不再散落,而是被声明成一条链
- 整条链可以统一
invoke / stream / batch - 节点可以被动态增强
为什么声明式链路这么重要
因为 AI 系统最难维护的,通常不是单个节点,而是节点之间的关系。
比如这些典型场景:
- 顺序处理:
RunnableSequence - 条件分支:
RunnableBranch - 并行计算:
RunnableMap - 自定义转换:
RunnableLambda - 保留原始输入:
RunnablePassthrough - 带会话历史执行:
RunnableWithMessageHistory
这套东西拼起来后,AI 应用才从“多个 API 调用”变成“一个工作流”。
LCEL 最强的地方,其实是节点级治理
原始资料里提到的几个能力,我认为都非常重要:
withRetrywithFallbackswithConfigcallbacks
它们之所以关键,是因为它们说明了一件事:
LCEL 不只是把节点连起来,还允许你在节点上动态叠加治理能力。
这就很像工业流水线上的每一道工位:
- 可以单独观测
- 可以单独重试
- 可以单独切换备选方案
- 可以注入额外配置
一旦你理解到这一步,就会明白为什么后面的 LangSmith 会建立在 Runnable 和 callbacks 之上。
因为只有当链路被声明出来、节点边界清晰,监控和追踪才有抓手。
一个更接近工程实践的链路例子
const chain = RunnableSequence.from([
normalizeQuestion,
retrieveContext,
buildPrompt,
model,
parser,
]).withRetry({
stopAfterAttempt: 3,
});
这条链背后的工程意义比代码本身更重要:
- 输入先归一化
- 再检索知识
- 再组装上下文
- 然后调模型
- 最后做结果解析
之后如果你想:
- 流式输出:换成
stream - 批量处理:换成
batch - 观测每步耗时:加
callbacks - 检索失败重试:给 retriever 节点挂
withRetry
都不需要重写主流程结构。
这就是 LCEL 的价值。
这 6 个判断放在一起,LangChain 第一阶段真正学到的到底是什么
如果把这一阶段所有内容重新压缩,我会这样总结:
1. ChatModel 解决模型适配问题
它让你不用把业务逻辑写死在某一家模型的原生协议上。
2. PromptTemplate 解决上下文组织问题
它让 Prompt 从字符串,变成可维护的上下文组装层。
3. OutputParser 和 Structured Output 解决输出契约问题
它让模型结果能更稳定地进入业务系统,而不是停留在“看起来像对了”的自然语言层面。
4. Tool 和 MCP 解决动作执行问题
它让模型从“会回答”升级成“会决策并调用外部能力”。
5. Memory 和 RAG 解决上下文选择问题
它们都在回答:当前这一轮,到底该给模型看哪些信息。
6. LCEL 解决流程编排和治理问题
它把前面这些能力真正连接成一条可控、可观测、可扩展的工作流。
这 6 件事拼起来,才是一个完整 AI Agent 系统的骨架。
总结
学完 LangChain 第一阶段后,我最大的感受不是“又学会了几个库”,而是对 AI Agent 开发这件事有了更稳定的结构化理解。
过去很多人把 AI 应用理解成:
- 写个 Prompt
- 调个模型
- 返回一段答案
但只要你真正做过一点项目,就会知道远不止如此。
你真正要解决的是这几个层面的问题:
- 模型怎么解耦
- 上下文怎么组织
- 输出怎么约束
- 外部动作怎么接入
- 历史和知识怎么选择
- 整条流程怎么编排和观测
LangChain 这套体系,正好对应了这几个层面。
所以我现在对 LangChain 的判断很简单:
只学组件时,它是一套 AI 工具集。
学到 LCEL 之后,它才真正变成一套 AI 应用工程框架。
这也是为什么我认为,第一阶段学完之后,最值得带走的不是 API 记忆,而是这套“把 AI 系统拆成输入、输出、动作、记忆、知识和流程”的工程认知。
后面继续学 LangGraph、LangSmith,或者自己做更复杂的 Agent,真正会反复用到的,也正是这套认知框架,而不是某一个具体类名。