Agent 开发学习笔记(四):Model:调用与接入
前言
这是"从 LangChain 框架入门智能体开发"系列的第四篇。
前三篇分别讲了框架入门、Prompt 模板基础、Prompt 进阶。到了这一篇,正式进入 Model I/O 的中间环节——Model。
Prompt 篇解决的是"怎么把业务变量变成模型输入",Model 篇解决的是接下来的问题:输入有了,模型从哪来、怎么调、怎么配、怎么接、怎么省、怎么监控。
具体来说,本篇回答六个问题:
| 问题 | 对应节 |
|---|---|
| 模型是什么?LLM 和 ChatModel 什么关系? | 1. 模型是什么:LLM 与 ChatModel |
| 模型怎么调?一次性、流式还是批量? | 2. 模型怎么调:三种调用方式 |
| 模型怎么配?参数管什么? | 3. 模型怎么配:关键参数 |
| 模型怎么接?国内 API、本地、开源服务怎么选? | 4. 模型怎么接:四种接入方式 |
| 模型怎么省?重复请求怎么避免重复花钱? | 5. 模型怎么省:Caching 缓存 |
| 模型怎么监控?花了多少 token? | 6. 模型怎么监控:token 统计与 callback |
阅读建议:
- 第 1、2 节是基础,建议顺序读
- 第 3 节是参数速查,可以跳读,用到再回来查
- 第 4 节是重头,按你的接入场景选读对应小节
- 第 5、6 节是工程实践,接入跑通后再看
术语约定:本篇出现的"模型"是泛称,实际覆盖 LLM 和 ChatModel 两类。消息对象、AIMessage、token 等概念第一次出现时会给出定义。
代码约定:所有代码基于 LangChain 新版接口(langchain-core + langchain-community)。涉及 API Key 的地方用环境变量读取,需要提前配置。
1. 模型是什么:LLM 与 ChatModel
Model 篇要回答的第一个问题是:LangChain 里的"模型"到底指什么。
进入 LangChain 的 Model I/O 模块,你会看到两个名字:LLM 和 ChatModel。它们不是两个独立的东西,而是同一底层能力的两种接口形态。
1.1 两种接口,同一个底层能力
| LLM | ChatModel | |
|---|---|---|
| 中文叫法 | 基础文本生成模型接口 | 对话模型接口 |
| 输入 | 纯文本字符串 | 结构化消息列表(HumanMessage、SystemMessage 等) |
| 输出 | 纯文本字符串 | AIMessage 对象 |
| 适用场景 | 通用文本生成:续写、翻译、摘要 | 多轮对话、角色设定 |
| 底层关系 | 原生形态 | 基于 LLM 封装的对话形态,底层依然依赖 LLM 生成文本 |
两者不是同一个类,但共享同一个底层能力。LLM 是能力的基石,ChatModel 是面向对话场景的便捷封装。LangChain 用"一实一虚"来标注这层关系:LLM 是实线主路径,ChatModel 是虚线,表示它走的是同一套 Predict 逻辑,只是包装了一层对话格式。
帮助理解的一句话:
- LLM 是"通用接口",对话、续写、翻译都能用
- ChatModel 是"对话专用接口",专门为多轮对话、角色设定优化,底层还是调用 LLM 的能力
实际开发中,你更常打交道的会是 ChatModel,因为它直接对应 GPT-4、Claude、GLM-4 这些对话模型。但理解 LLM 是它的底层,能帮你在后面看自定义封装时想清楚:为什么继承的是 LLM 基类,而不是 ChatModel。
1.2 消息对象
ChatModel 的输入不是字符串,而是消息对象组成的列表。这是它和 LLM 最直接的区别。
消息对象是 LangChain 定义的标准格式,用来统一不同模型供应商的对话输入。不管底层是 OpenAI、Claude 还是 GLM,到了 LangChain 这一层,都表示成同一种消息对象。
LangChain 内置了五种消息类型:
| 消息类 | 对应 role | 用途 | 额外参数 |
|---|---|---|---|
| SystemMessage | system | 系统设定:身份、规则、语气、答题限制,全局生效 | — |
| HumanMessage | user / human | 用户输入 | — |
| AIMessage | assistant / ai | AI 回复 | — |
| FunctionMessage | function | 函数调用(function call)的结果 | name(对应函数名) |
| ToolMessage | tool | 工具调用(tool call)的结果 | tool_call_id(对应工具 id) |
函数调用和工具调用留到后续部分展开,这里只需要知道它们也是消息对象的一种。
前三类是最常用的。它们有固定角色的快捷方式:
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
SystemMessage(content="你是专业助手,回答简洁精准")
HumanMessage(content="你好")
AIMessage(content="你好,有什么可以帮你?")
content 是核心字段,放真实文本。除此之外,AIMessage 还自带两个固定字段:
| 字段 | 内容 |
|---|---|
| content | 核心回答内容 |
| additional_kwargs | 工具调用、角色等附加信息 |
| response_metadata | 接口返回元数据(token 用量、模型名、停止原因等) |
注意:AIMessage 是 Python 类实例,不是纯字符串。要拿真实回答文本,用 对象.content:
response = chat.invoke([HumanMessage(content="你好")])
print(response.content) # 真实文本
print(response) # AIMessage(content='...', ...)
除了前三种固定角色的快捷方式,LangChain 还有一个底层基类 ChatMessage,允许任意 role:
from langchain_core.messages import ChatMessage
# 以下等价
SystemMessage(content="你是助手")
ChatMessage(content="你是助手", role="system")
HumanMessage(content="你好")
ChatMessage(content="你好", role="user")
AIMessage(content="你好")
ChatMessage(content="你好", role="assistant")
# 自定义角色,只能用 ChatMessage
ChatMessage(content="愿上帝与你同在", role="Jedi")
为什么需要 ChatMessage?因为有些模型 API(如 Claude、OpenAI 的某些扩展功能)支持非标准角色名。前三种快捷方式覆盖不了这种场景,ChatMessage 可以。
三类消息统一记忆:
| 消息类 | 对应角色 | 用途 |
|---|---|---|
| SystemMessage | system | 系统设定 |
| HumanMessage | user / human | 用户输入 |
| AIMessage | assistant / ai | AI 回复 |
| ChatMessage | 任意 | 通用 / 自定义角色 |
消息对象怎么变成模型输入,涉及到消息模板的组装,详见《Prompt 模板基础》篇。
知道了模型是什么、输入长什么样,下一步看怎么调用它。
2. 模型怎么调:三种调用方式
模型接好了,接下来是让它干活。LangChain 提供了三种调用方式,对应三种不同的使用场景。
2.1 invoke:一次性返回
invoke 是最基础的调用方式:发出请求,等待模型生成完整回复,一次性拿到结果。
对于 ChatModel,输入是消息对象列表:
from langchain_community.chat_models import ChatZhipuAI
from langchain_core.messages import HumanMessage, SystemMessage
import os
chat = ChatZhipuAI(
api_key=os.getenv("ZHIPU_API_KEY"),
model="glm-4-flash",
)
response = chat.invoke([
SystemMessage(content="你是专业助手,回答简洁精准"),
HumanMessage(content="请介绍一下自己"),
])
print(response.content)
返回的是 AIMessage 对象,要拿真实文本用 .content。
invoke 的特点是阻塞:代码会一直等到模型生成完最后一个 token 才继续往下走。适合不需要实时反馈的场景,比如后台任务、批量处理中的单次调用。
2.2 stream:流式返回
stream 和 invoke 的核心区别是:invoke 一次返回完整结果,stream 循环逐段返回。你需要在代码里写一个循环来接收片段。
import os
from zai import ZhipuAiClient
client = ZhipuAiClient(api_key=os.getenv("ZHIPU_API_KEY"))
response = client.chat.completions.create(
model="glm-4-flash",
messages=[{"role": "user", "content": "你好,请介绍一下自己"}],
stream=True,
)
for chunk in response:
print(chunk.choices[0].delta.content, end="")
流式返回的每个片段是 delta.content,可能是单个字符、一个词或一小段文本。用 end="" 取消换行,片段会连续打印出来,形成打字机效果。
stream 的适用场景很明确:用户会盯着屏幕等回复。比如聊天界面、代码补全工具。如果用户看不到实时输出(比如后台任务),用 stream 没有意义,反而增加代码复杂度。
2.3 batch:批量返回
batch 接收一个输入列表,返回一个结果列表,顺序和输入一致。
responses = chat.batch([
[HumanMessage(content="1+1等于几?")],
[HumanMessage(content="2+2等于几?")],
[HumanMessage(content="3+3等于几?")],
])
for r in responses:
print(r.content)
batch 和 for 循环 invoke 的区别在于:batch 的默认实现是用并行方式跑多个 invoke——三个请求可能同时发出去,而不是等第一个完成再发第二个。
但这不意味着 batch 总是更快。它有一个延迟开销:需要收集所有输入、一次性发送、等待所有结果返回。对于少量输入(比如 1-2 个),用 invoke 循环反而更直接。通常 10 个以上才值得考虑。
batch 的价值在输入数量多、且彼此独立的场景——比如翻译 100 句话、对 50 条评论做情感分析。输入量大时,它能自动并行发起多个 invoke,而不是等上一个完成再发下一个。但并行度需要控制,可以用 max_concurrency 限制同时进行的请求数,避免触发 API 限流。
responses = chat.batch(inputs, config={"max_concurrency": 5})
2.4 对比表
| 方式 | 输入 | 返回 | 特点 | 适用场景 |
|---|---|---|---|---|
| invoke | 消息对象列表 | AIMessage | 阻塞,一次拿到完整结果 | 单次请求,用户等待时间可接受 |
| stream | 消息对象列表 | 逐个 chunk(delta.content) | 循环接收,实时输出 | 用户盯着屏幕看输出 |
| batch | 多个消息对象列表的列表 | AIMessage 列表 | 并行调用,顺序返回 | 大批量输入,无需实时反馈 |
调用方式定了,但同样的输入,模型可能给出质量差异很大的输出——接下来看参数怎么调。
3. 模型怎么配:关键参数
调用方式定了,接下来是参数。同样一句提示词,参数不同,输出质量可能差很远。
ChatModel 的关键参数有五个:max_tokens、temperature、top_p、frequency_penalty、presence_penalty。
3.1 max_tokens
控制模型一次最多输出多少 token。
| 说明 | |
|---|---|
| 上限约束 | 不能超过「模型总窗口 − 当前输入已用 token」 |
| 主动限制 | 在剩余空间内,再设一个更小的天花板 |
举例:gpt-3.5-turbo-16k 总窗口 16k,输入用了 2k,剩余 14k。设 max_tokens=500,模型最多输出 500;设 14000,最多输出 14k。
设置建议:
| 设太小 | 设太大 | 不设置 |
|---|---|---|
| 回答被截断、没说完 | 输出冗长、浪费钱、响应慢 | 默认用满剩余空间,可能失控 |
注意:所有输入(历史对话、系统提示、当前问题)都算作输入 token,一并计费。
3.2 temperature
控制随机性 / 创造性。取值一般 0~2。
| 取值 | 效果 | 适合 |
|---|---|---|
| 越接近 0 | deterministic,回答固定、严谨 | 代码、推理、问答 |
| 越接近 2 | 脑洞大、随机、发散 | 文案创作、写诗、脑洞内容 |
业务场景口诀:
| 场景 | 建议值 |
|---|---|
| 写代码、数据分析、知识库问答 | 0.1~0.3 |
| 日常聊天、文案写作 | 0.7~1.0 |
3.3 top_p
核采样:从概率总和前 top_p 的词里选,压缩候选词范围。
和 temperature 是两套控制随机的方案:
| 取值 | 效果 |
|---|---|
| top_p=0.1 | 只选概率极高的一小撮词,回答保守、稳定 |
| top_p=0.9 | 允许纳入更多低概率新词,回答更多样 |
行业常用搭配:一般只调 temperature,top_p 固定 0.9 不动。两个同时乱调容易效果混乱。
3.4 frequency_penalty
频率惩罚:降低重复用词、重复句式。
针对已经反复出现过的词语加大惩罚。值越高,越讨厌老生常谈、反复叠词。作用是减少复读机、句子循环、话术重复。
3.5 presence_penalty
存在惩罚:鼓励新开话题、拓展新内容。
针对只要上文出现过的内容就轻微打压。值越高,越倾向多说新观点、新角度、不局限前文。作用是避免模型一直围着一个点绕圈,提升内容丰富度。
3.6 总表
| 参数 | 作用 |
|---|---|
| max_tokens | 限制模型最大输出长度,结合输入 token 不能超过模型上下文上限 |
| temperature | 温度,控制输出随机性,越低越严谨,越高越有创造性 |
| top_p | 核采样,从累计概率靠前的 token 中采样,用于控制输出多样性 |
| frequency_penalty | 频率惩罚,抑制词语重复出现,减少文本复读 |
| presence_penalty | 存在惩罚,弱化已有内容,鼓励模型输出新观点、新内容 |
参数在初始化模型时传入:
chat = ChatZhipuAI(
model="glm-4-flash",
api_key=os.getenv("ZHIPU_API_KEY"),
temperature=0.3,
max_tokens=500,
)
参数会配了,但模型从哪来?接下来看四种接入方式。
4. 模型怎么接:四种接入方式
模型从哪来?这一节覆盖四种接法:国内大模型 API、本地大模型、开源模型的 OpenAI 兼容服务,以及贯穿前三种的自定义封装规范。
4.1 接入国内大模型:GLM-4
GLM-4 是智谱 2024 年发布的新一代基座大模型,整体性能相比 GLM3 提升 60%,逼近 GPT-4,支持更长上下文、多模态、更快推理,并增强了智能体能力。
接入 GLM-4 有三种方式,由外到内依次是:原生 SDK 直连、社区封装、自定义封装。
方式一:原生 SDK 直连
不依赖 LangChain,直接用智谱官方 zai-sdk 请求接口。
pip install zai-sdk
API Key 放入系统环境变量 ZHIPU_API_KEY。如果程序报错取值为 None,大概率是环境变量未生效——新增/修改后必须重启终端 / IDE。
非流式调用:
import os
from zai import ZhipuAiClient
client = ZhipuAiClient(api_key=os.getenv("ZHIPU_API_KEY"))
response = client.chat.completions.create(
model="glm-4-flash",
messages=[
{"role": "user", "content": "你好,请介绍一下自己"}
]
)
print(response.choices[0].message.content)
流式调用只需加 stream=True,然后循环接收:
response = client.chat.completions.create(
model="glm-4-flash",
messages=[{"role": "user", "content": "你好,请介绍一下自己"}],
stream=True,
)
for chunk in response:
print(chunk.choices[0].delta.content, end="")
原生 SDK 适合独立项目快速调用、单纯实现对话问答,上手简单、轻量化。但它无法对接 LangChain 生态的链式调用、智能体、RAG 检索等功能。要融入 LangChain,就需要后两种方式。
方式二:社区封装
LangChain 社区已经适配了智谱新接口,可以直接用 ChatZhipuAI:
import os
from langchain_community.chat_models import ChatZhipuAI
chat = ChatZhipuAI(
api_key=os.getenv("ZHIPU_API_KEY"),
model="glm-4-flash",
)
注意:LangChain 早期内置的旧版智谱模型类已因接口从 v1 升级至 v4 全面失效。
# 失效旧导入
from langchain.llms import ZhipuAI
# 可用社区封装
from langchain_community.chat_models import ChatZhipuAI
社区封装是最省事的方式,适合快速接入已适配的模型。
方式三:自定义封装
即便已有现成封装,手动自定义封装依旧有价值:可以吃透 LangChain 大模型统一接入规范,后续对接任意本地开源模型、小众私有模型,都能复用这套代码结构。
把 GLM-4 封装成 LangChain 兼容的自定义 LLM 组件:
from typing import ClassVar, List, Dict
from langchain_core.language_models.llms import LLM
from langchain_core.messages.ai import AIMessage
import os
from zai import ZhipuAiClient
zhipuai_api_key = os.getenv("ZHIPU_API_KEY")
class ChatGLM4(LLM):
history: ClassVar[List[Dict]] = []
client: object = None
def __init__(self):
super().__init__()
self.client = ZhipuAiClient(api_key=zhipuai_api_key)
@property
def _llm_type(self):
return "ChatGLM4"
def invoke(self, prompt, history=[]):
if history is None:
history = []
history.append({"role": "user", "content": prompt})
response = self.client.chat.completions.create(
model="glm-4-flash",
messages=history
)
result = response.choices[0].message.content
return AIMessage(content=result)
def _call(self, prompt, history=[]):
return self.invoke(prompt, history)
def stream(self, prompt, history=[]):
if history is None:
history = []
history.append({"role": "user", "content": prompt})
response = self.client.chat.completions.create(
model="glm-4-flash",
messages=history,
stream=True
)
for chunk in response:
yield chunk.choices[0].delta.content
llm = ChatGLM4()
res = llm.invoke('请讲一个小猫咪的笑话')
print(res.content)
关键设计点:
| 设计点 | 说明 |
|---|---|
| LangChain 兼容 | 继承 LLM 基类、实现 _call 和 _llm_type(LLM 基类强制要求的抽象方法和属性),确保能被 Chain、Agent 等组件识别和调用 |
| 对话历史管理 | 每次调用自动将用户输入追加到 history 列表,支持多轮上下文 |
| 流式与非流式双支持 | invoke 一次性返回完整结果,stream 逐段生成 |
自定义封装和社区封装的关系
两者都是把目标模型接进 LangChain,只是谁来做的问题。两条路径终点一样,都是让 GLM-4 能进 LangChain。区别只是 ChatZhipuAI 是社区写好的,ChatGLM4 是你自己写的。
| 社区封装 | 自定义封装 | |
|---|---|---|
| 谁写 | LangChain 社区 / 官方 | 你自己 |
| 目标 | 把某个模型接进 LangChain | 把某个模型接进 LangChain |
| 底层依赖 | 该模型的官方 SDK(如 zai-sdk) | 该模型的官方 SDK(如 zai-sdk) |
| 结果 | ChatZhipuAI 类 | 你的 ChatGLM4 类 |
| 关系 | 别人替你写好了 | 你自己写 |
4.2 接入本地大模型:ChatGLM3-6B
本地模型和 API 模型的核心差异在推理方式:本地模型通过 transformers 库加载模型权重文件,在本地硬件上执行前向推理;API 模型通过 HTTP 请求调用远程服务。
封装本地模型时,有哪些必须写、哪些推荐写、哪些不需要写:
| 类别 | 内容 |
|---|---|
| 必须写 | 继承 LLM 基类;_llm_type 属性;_call() 方法 |
| 推荐写 | load_model() 加载本地模型文件;stream() 流式输出 |
| 不需要全写 | 类属性(max_token、temperature 等)可以不写,用默认;构造函数 __init__ 空着也能跑;不是所有参数都要定义,按需写即可 |
完整封装代码:
from langchain_core.language_models.llms import LLM
from transformers import AutoTokenizer, AutoModel
from langchain_core.messages.ai import AIMessage
from typing import ClassVar, List, Dict
class ChatGLM3(LLM):
max_token: int = 8192
do_sample: bool = True
temperature: float = 0.3
top_p: float = 0.0
tokenizer: object = None
model: object = None
history: ClassVar[List[Dict]] = []
def __init__(self):
super().__init__()
@property
def _llm_type(self):
return "ChatGLM3"
def load_model(self, modelPath=None):
tokenizer = AutoTokenizer.from_pretrained(
modelPath, trust_remote_code=True, use_fast=True
)
model = AutoModel.from_pretrained(
modelPath, trust_remote_code=True, device_map="auto"
)
model = model.eval()
self.model = model
self.tokenizer = tokenizer
def _call(self, prompt, config={}, history=[]):
return self.invoke(prompt, history)
def invoke(self, prompt, config={}, history=[]):
if not isinstance(prompt, str):
prompt = prompt.to_string()
response, history = self.model.chat(
self.tokenizer,
prompt,
history=history,
do_sample=self.do_sample,
max_length=self.max_token,
temperature=self.temperature
)
self.history = history
return AIMessage(content=response)
def stream(self, prompt, config={}, history=[]):
if not isinstance(prompt, str):
prompt = prompt.to_string()
preResponse = ""
for response, new_history in self.model.stream_chat(self.tokenizer, prompt):
if preResponse == "":
result = response
else:
result = response[len(preResponse):]
preResponse = response
yield result
实例化并加载模型:
llm = ChatGLM3()
modelPath = "H:\AI\Agent\chatglm3-6b"
llm.load_model(modelPath)
调用:
llm.invoke("中国的首都是?")
流式输出:
for response in llm.stream("写一首春节的诗"):
print(response, end="")
模型文件通常有好几个超 2GB 的文件,下载时间较长。本节可以只听不实践,理解封装结构即可。
4.3 接入开源模型的 OpenAI 兼容服务
LangChain 直接加载本地大模型耗时较长,更推荐提前加载模型并启动为后端服务,同时将接口设计为与 OpenAI 一致。这样后续就能像请求 OpenAI 接口一样调用本地模型。
把本地开源大模型部署成标准 OpenAI 风格接口,常用的三个工具:
| 工具 | 定位 | 核心特点 |
|---|---|---|
| LM Studio | 桌面应用,易用 | 图形界面下载模型、内置 OpenAI 兼容 API、本地运行保护数据隐私 |
| vLLM | 高并发高性能推理框架 | PagedAttention 算法、吞吐量比 HuggingFace Transformers 高 24 倍 |
| API for Open LLMs | 多模型统一后端 | 支持 ChatGLM / LLaMA / MOSS 等,OpenAI 相似响应,支持 Docker 启动 |
启动服务后,用 ChatOpenAI 指向本地地址:
from langchain_openai import ChatOpenAI
openai_api_key = "EMPTY"
openai_api_base = "http://127.0.0.1:1234/v1"
chat = ChatOpenAI(
openai_api_key=openai_api_key,
openai_api_base=openai_api_base,
temperature=0.7,
)
chat.invoke("请问2只兔子有多少条腿?")
也可以和 Prompt、Parser 组合成链:
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_template("请根据下面的主题写一篇小红书营销的短文: {topic}")
output_parser = StrOutputParser()
chain = prompt | chat | output_parser
chain.invoke({"topic": "康师傅绿茶"})
这里的 | 是 LCEL 的管道语法,详见《LCEL:把三件套串起来》篇。
流式输出:
for chunk in chain.stream({"topic": "康师傅绿茶"}):
print(chunk, end="")
4.4 自定义封装的规范
前三种接入方式里,本地模型和自定义封装都要继承 LangChain 的 LLM 基类。这套规范是通用的:LangChain 不认识的目标模型 API,都需要通过相同的方式封装——本质是做一个"翻译器",把 LangChain 的标准调用翻译成目标模型能识别的请求格式,再把模型返回结果翻译回 LangChain 格式。
继承谁
from langchain_core.language_models.llms import LLM
继承 LLM 基类后,自定义模型自动继承 Runnable 特性,无需额外实现就能用 invoke、batch 等标准方法。
二选一:_generate vs _call
| 写法 | 说明 | 适用 |
|---|---|---|
_generate(推荐) | 支持返回完整 LLMResult 结构体,能附带 token 消耗、停止原因、模型信息等元数据 | 生产场景 |
_call(兼容) | 只接收字符串提示、返回字符串结果,无法获取 token 等额外信息 | 极简场景 |
必写:_llm_type
无论重写哪种核心方法,都必须实现 _llm_type 属性(用 @property 装饰),返回一个自定义的模型类型字符串(如 "ollama_custom"、"my_local_llm"),用于 LangChain 内部识别模型类型。
调用链
现代写法(重写 _generate):
BaseLLM.invoke → LLM.generate → LLM._generate(我们重写的方法)
invoke 方法已经在 LLM 基类中实现好了,底层自动调用 generate,而 generate 又会调用我们重写的 _generate。只要实现 _generate,整个调用链路就自动打通。
旧版写法(重写 _call):
BaseLLM.invoke → LLM.generate → LLM._generate(基类默认实现) → LLM._call(我们重写的方法)
LangChain 基类已做双向兼容,两种写法都能正常运行,但优先推荐 _generate。
三个误区
| 误区 | 澄清 |
|---|---|
| 封装仅适用于本地模型 | 错误。封装不分本地/线上,核心是"LangChain 不认识的模型 API,都需要封装" |
| token 消耗是 LangChain 计算的 | 错误。token 由模型官方(如通义千问、OpenAI)计算,LangChain 仅负责提取并展示 |
必须同时重写 _generate 和 _call | 错误。二者二选一即可,基类会自动兼容另一种 |
4.5 四种方式对比
| 方式 | 依赖 | 适用场景 | 代码量 |
|---|---|---|---|
| 国内 API | 社区封装 / 自定义 | 快速接入线上模型 | 少 |
| 本地模型 | transformers + 自定义 | 离线、数据隐私 | 多 |
| 开源 OpenAI 服务 | ChatOpenAI | 本地部署 + 高并发 | 少 |
| 自定义封装 | LLM 基类 | 小众模型、私有部署 | 多 |
模型接上了,但每次调用都要花钱。接下来看怎么省。
5. 模型怎么省:Caching 缓存
LangChain 为模型提供了可选的缓存层。重复请求相同内容时,缓存可以直接返回上次的结果,不再调用模型服务商 API。原因有两个:
| 理由 | 说明 |
|---|---|
| 节省成本 | 重复请求相同内容时,缓存可以避免重复调用 API,从而节省费用 |
| 提高响应速度 | 通过减少 API 调用次数来加速应用程序 |
5.1 InMemoryCache:内存缓存
设置缓存:
from langchain_core.globals import set_llm_cache
from langchain_core.caches import InMemoryCache
set_llm_cache(InMemoryCache())
set_llm_cache 是全局设置:一旦设置,之后所有模型调用都会走缓存,不需要给每个模型单独配置。
首次提问:
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_template("请根据下面的主题写一篇小红书营销的短文: {topic}")
output_parser = StrOutputParser()
chain = prompt | chat | output_parser
chain.invoke({"topic": "康师傅绿茶"})
耗时:
Wall time: 8.64 s
再次提问同样的内容:
chain.invoke({"topic": "康师傅绿茶"})
耗时:
Wall time: 2 ms
8.64s 到 2ms。具体数值因环境和模型而异,重点是数量级差异——缓存命中时几乎不耗时。
InMemoryCache 存在内存里,程序一关缓存就没了。适合调试、临时运行。
5.2 SQLiteCache:磁盘缓存
SQLite 缓存把结果存在磁盘的一个 .db 文件里,关机重启都还在。这就是磁盘缓存,也叫持久化缓存。
from langchain_community.cache import SQLiteCache
set_llm_cache(SQLiteCache(database_path=".langchain.db"))
首次提问:
chain.invoke({"topic": "旺仔小馒头"})
耗时:
Wall time: 7.65 s
再次提问:
chain.invoke({"topic": "旺仔小馒头"})
耗时:
Wall time: 3 ms
生成的 .db 文件会落在指定路径。
SQLite 缓存的优点:
| 优点 | 说明 |
|---|---|
| 一次调用,永久生效 | 只要 .db 文件还在,缓存就一直有效 |
| 不重复扣 token | 同样的问题,永远不会重复调用 API |
| 速度快 | 比重新调用模型快 10~100 倍 |
| 不占内存 | 存在硬盘里,安全持久 |
适合开发 + 生产环境。
5.3 RedisCache
除了内存和磁盘,还有 RedisCache 这类分布式持久化方案。适合多进程、多机器共享缓存的场景。这里不展开。
5.4 对比表
| 缓存类型 | 存在哪里 | 重启程序后还在吗 | 适合场景 |
|---|---|---|---|
| InMemoryCache | 内存 | 否 | 调试、临时运行 |
| SQLiteCache | 磁盘文件 | 是 | 正式使用、反复运行 |
省了钱,还要知道花了多少。接下来看 token 统计。
6. 模型怎么监控:token 统计与 callback
模型调用了,缓存也配了,但每次调用到底花了多少钱?这一节解决 token 统计。
先定义 token:token 是模型处理文本的最小单位。模型不直接读字符,而是把文本切成 token 再处理。输入和输出都按 token 计费。中文里一个 token 大约对应 1~2 个汉字,英文里一个 token 大约对应 3~4 个字符。
6.1 从 response_metadata 拿
第 1 节讲过,AIMessage 自带 response_metadata 字段。token 用量就在里面:
from langchain_community.chat_models import ChatZhipuAI
from langchain_core.messages import HumanMessage
import os
chat = ChatZhipuAI(
api_key=os.getenv("ZHIPU_API_KEY"),
model="glm-4-flash",
)
response = chat.invoke([HumanMessage(content="你好")])
print(response.response_metadata)
输出里含 token_usage:
{
'token_usage': {
'completion_tokens': 15,
'prompt_tokens': 13,
'total_tokens': 28
},
'model_name': 'glm-4-flash',
'finish_reason': 'stop'
}
三个字段的含义:
| 字段 | 含义 |
|---|---|
| prompt_tokens | 输入消耗的 token |
| completion_tokens | 输出消耗的 token |
| total_tokens | 总消耗,计费依据 |
每次调用后手动取一次,适合单次调试。
6.2 用 callback 记录
如果要在不侵入业务代码的前提下统计多次调用,用 callback。
LangChain 的 callback 系统允许你在执行生命周期的特定节点插入逻辑。用于 token 统计时,核心是 on_llm_end 事件——LLM 调用结束时触发,可以从 response.llm_output 中提取 token_usage。
一个自定义的 token 统计 callback:
from langchain_core.callbacks import BaseCallbackHandler
class TokenCounter(BaseCallbackHandler):
def __init__(self):
self.total_prompt = 0
self.total_completion = 0
def on_llm_end(self, response, **kwargs):
usage = response.llm_output.get("token_usage", {})
self.total_prompt += usage.get("prompt_tokens", 0)
self.total_completion += usage.get("completion_tokens", 0)
print(f"本次调用: prompt={usage.get('prompt_tokens', 0)}, "
f"completion={usage.get('completion_tokens', 0)}")
def report(self):
print(f"\n累计: prompt={self.total_prompt}, "
f"completion={self.total_completion}, "
f"total={self.total_prompt + self.total_completion}")
counter = TokenCounter()
chat.invoke([HumanMessage(content="你好")], config={"callbacks": [counter]})
chat.invoke([HumanMessage(content="再见")], config={"callbacks": [counter]})
counter.report()
把 TokenCounter 实例通过 config={"callbacks": [counter]} 传给每次调用,所有调用的 token 用量会自动累加。
如果你用的模型是 OpenAI,LangChain 有现成的 get_openai_callback:
from langchain_community.callbacks import get_openai_callback
with get_openai_callback() as cb:
chat.invoke([HumanMessage(content="你好")])
print(f"Total: {cb.total_tokens}, Cost: ${cb.total_cost}")
但这是 OpenAI 专用的,GLM-4 等其他模型用不了,需要走上面的自定义 callback 方式。
callback 不止用于 token 统计。 它还能做日志、调试、追踪、性能测量等。token 统计只是其中一个用途。
6.3 误区
| 误区 | 澄清 |
|---|---|
| token 消耗是 LangChain 计算的 | 错误。token 由模型官方(如智谱、OpenAI)计算,LangChain 仅从 API 返回中提取并展示 |
跨篇引用:LangSmith 是 LangChain 官方的追踪平台,能可视化每次调用的 token 用量、耗时、链路,详见《踩坑与调试》篇。
Model 篇到这里结束。模型是什么、怎么调、怎么配、怎么接、怎么省、怎么监控,六个问题都覆盖了。下一篇看 Parser:怎么把模型的输出变成结构化数据。
小结
六个问题,一张表
| 问题 | 节 | 核心答案 |
|---|---|---|
| 模型是什么 | 1 | LLM 与 ChatModel 是同一底层能力的两种接口形态;ChatModel 用消息对象作为输入输出 |
| 模型怎么调 | 2 | invoke(一次性)/ stream(流式)/ batch(批量) |
| 模型怎么配 | 3 | max_tokens / temperature / top_p / frequency_penalty / presence_penalty |
| 模型怎么接 | 4 | 国内 API / 本地模型 / 开源 OpenAI 服务 / 自定义封装 |
| 模型怎么省 | 5 | InMemoryCache(内存)/ SQLiteCache(磁盘)/ RedisCache(分布式) |
| 模型怎么监控 | 6 | response_metadata 取 token_usage;callback 批量统计 |
核心概念清单
| 概念 | 定义 |
|---|---|
| LLM | 基础文本生成模型接口,输入输出均为纯字符串 |
| ChatModel | 对话模型接口,输入消息对象列表,输出 AIMessage |
| 消息对象 | SystemMessage / HumanMessage / AIMessage / FunctionMessage / ToolMessage / ChatMessage |
| AIMessage | ChatModel 的返回对象,真实文本在 .content,元数据在 response_metadata |
| invoke | 一次性调用,阻塞直到完整返回 |
| stream | 流式调用,循环接收 chunk |
| batch | 批量调用,并行执行多个 invoke,用 max_concurrency 控制并发 |
| 自定义封装 | 继承 LLM 基类,实现 _llm_type + _call 或 _generate,把非标准模型接进 LangChain |
| 缓存 | set_llm_cache 全局设置,命中时直接返回上次结果,不调 API |
| token | 模型处理文本的最小单位,输入输出都按 token 计费 |
关键规律
接入方式选择:
| 场景 | 推荐方式 |
|---|---|
| 快速接入线上主流模型 | 社区封装(如 ChatZhipuAI) |
| 离线、数据隐私 | 本地模型 + 自定义封装 |
| 本地部署 + 高并发 | OpenAI 兼容服务 + ChatOpenAI |
| 小众模型、私有部署 | 自定义封装 |
缓存选择:
| 场景 | 推荐 |
|---|---|
| 调试、临时运行 | InMemoryCache |
| 正式使用、反复运行 | SQLiteCache |
| 多进程、多机器共享 | RedisCache |
参数速查:
| 参数 | 一句话 |
|---|---|
| max_tokens | 限制输出长度 |
| temperature | 控制随机性,越低越严谨 |
| top_p | 核采样,一般固定 0.9 不动 |
| frequency_penalty | 抑制词语重复 |
| presence_penalty | 鼓励新观点 |
跨篇衔接
- 消息对象的消息模板组装 → 《Prompt 模板基础》篇
- LCEL 管道语法
|→ 《LCEL:把三件套串起来》篇 - LangSmith 追踪 → 《踩坑与调试》篇
下一篇
Model 篇结束,接下来是 Parser:怎么把模型的输出变成结构化数据。