一、基础概念
1.1、Token(词元)
Token 成本
在调用 LLM 时,你不是按"字符数"或"消息条数"付费 ,而是按 Token 数 付费。Token 是模型把文本切分后得到的"最小语义单元"------大致可以理解为"一个词"或"一个词根"。例如 hamburger 会被切成 ham + bur + ger 三个 Token,而 the 通常就是 1 个 Token。
Token ≈ 4 个英文字符 ≈ 0.75 个英文单词 ≈ 0.5 个汉字(粗略估算;不同模型分词器略有差异)。
为什么 Token 成本很重要?因为一次 Agent 调用通常不是"发一句话、收一句话"那么简单。以 Anthropic Claude 为例,费用分三档:
| Token 类型 | 含义 | 典型单价(Claude Sonnet 参考) |
|---|---|---|
| input_tokens | 你发给模型的全部 prompt(system + 历史 + 当前问题) | $3 / 1M |
| output_tokens | 模型生成的回答(含 thinking) | $15 / 1M |
| cache_read_tokens | 命中 prompt cache 的部分,按更低单价计费 | $0.30 / 1M |
| cache_creation_tokens | 首次写入 cache 的部分,单价略高于 input | $3.75 / 1M |
⚠️ 注意:output_tokens 通常比 input 贵 5 倍左右,所以"让模型少废话"比"让模型多读点"更省钱。这也是为什么 Agent 系统会严格控制回答格式(比如要求 JSON、要求简短),而不是放任模型自由发挥。
Token 预算/估算
在写 Agent 时,你需要建立"Token 预算"的心智模型------上下文窗口(context window)是有限的,每次调用都要在心里盘算:
markdown
总 Token 预算 = context_window(如 200K)
= system_prompt(固定开销,~1-5K)
+ 历史消息(随对话增长,~1-50K)
+ 当前输入 + RAG 检索片段(~2-10K)
+ 模型输出(预留,~1-4K)
+ thinking(reasoning,~0.5-32K)
+ 工具调用中间结果(~2-20K)
当总 Token 接近 context_window 的 75%~90% 时,就需要触发上下文压缩(summarize 历史、丢弃无关片段),否则模型会"遗忘"早期信息或直接报错。
实际估算 Token 数可以用 LangChain 提供的 tokenizer:
javascript
import { TokenTextSplitter } from '@langchain/textsplitters';
// 估算一段中文文本大约消耗多少 Token
const splitter = new TokenTextSplitter({
chunkSize: 1000,
chunkOverlap: 0,
});
const text = '这是一段用于估算 Token 数量的中文文本,用来演示如何预算上下文空间。';
const chunks = await splitter.splitText(text);
console.log(`约 ${chunks.length} 个分块,每块上限 1000 Token`);
实战建议 :在 Agent 运行循环里,每一轮(turn)开始前先算一次当前上下文已用 Token,超过阈值就压缩------可以用 TokenTextSplitter 配合 usage_metadata 实现 token 监控器。
1.2、Prompt 提示词
模型本身没有"任务"概念------它只知道"根据上下文预测下一个 Token"。你必须通过 Prompt 告诉它要做什么、怎么做、有什么约束。
消息角色
在 LangChain/LangGraph 里,一次对话由一个消息列表(MessageList) 组成,每条消息带一个角色(role)。常见角色:
| 角色 | 谁产生的 | 作用 | 对应类型 |
|---|---|---|---|
| system | 开发者 | 定义 Agent 的"人格"、能力边界、行为规范 | SystemMessage |
| user | 用户 | 用户提出的问题 / 任务 | HumanMessage |
| assistant | 模型 | 模型的回答(含 thinking) | AIMessage |
| tool | 工具 | 工具执行后回传给模型的结果 | ToolMessage |
为什么要有角色区分?因为模型在训练时学会了"按角色立场理解语义"------system 消息拥有最高优先级(模型会严格遵循),user 消息是任务来源,assistant 消息是"自己说过的话"(用于保持对话连贯),tool 消息是"外部世界的反馈"。
消息类型
LangChain 1.x 里所有消息都来自 @langchain/core/messages:
go
import {
SystemMessage,
HumanMessage,
AIMessage,
ToolMessage,
} from '@langchain/core/messages';
const messages = [
// system:定义 Agent 行为
new SystemMessage({
content: '你是一个严格按 JSON 格式输出的助手,禁止输出多余文字。',
}),
// user:用户提问
new HumanMessage({
content: '北京今天天气怎么样?',
}),
// assistant:模型回答(可能含 tool_calls,表示模型决定调用工具)
new AIMessage({
content: '',
tool_calls: [
{
name: 'get_weather',
args: { city: 'Beijing' },
id: 'call_001',
type: 'tool_call',
},
],
}),
// tool:工具执行结果回传给模型
new ToolMessage({
content: '{"temp": 22, "condition": "sunny"}',
tool_call_id: 'call_001',
}),
];
⚠️ ToolMessage 的 tool_call_id 必须与对应的 AIMessage.tool_calls[].id 严格对应------模型靠这个 id 把"调用"和"结果"配对。配对错乱会导致模型困惑或报错。
Prompt 工程化(ChatPromptTemplate)
手动拼消息列表很繁琐------参数顺序容易错、变量散落各处、模板复用难。LangChain 提供 ChatPromptTemplate 把 prompt 参数化、模板化、可组合:
javascript
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { ChatAnthropic } from '@langchain/anthropic';
// ① 基础模板:用 {variable} 占位
const promptTemplate = ChatPromptTemplate.fromMessages([
['system', '你是一个{role},回答用{language},不超过{maxWords}字。'],
['human', '{question}'],
]);
// ② 用变量渲染消息列表
const messages = await promptTemplate.formatMessages({
role: 'Python 导师',
language: '中文',
maxWords: 100,
question: '什么是装饰器?',
});
// ③ 调用模型
const model = new ChatAnthropic({ model: 'claude-sonnet-4-20250514' });
const response = await model.invoke(messages);
进阶用法:MessagesPlaceholder 插入动态消息列表------在模板里预留位置,把历史对话、Few-shot 示例、RAG 检索结果动态塞进去:
php
import { MessagesPlaceholder } from '@langchain/core/prompts';
// 模板里预留 {chat_history} 占位
const templateWithHistory = ChatPromptTemplate.fromMessages([
['system', '你是一个客服助手,根据历史对话回答用户问题。'],
new MessagesPlaceholder('chat_history'), // ← 动态位置
['human', '{question}'],
]);
// 渲染时填入历史消息
const messages = await templateWithHistory.formatMessages({
chat_history: [
{ role: 'human', content: '我的订单还没收到' },
{ role: 'ai', content: '请提供订单号' },
{ role: 'human', content: '订单号是 #12345' },
],
question: '现在物流到哪里了?',
});
Few-shot 示例引导格式 :在 prompt 里塞几个"问题 → 标准答案"的示例,模型会模仿示例的格式和风格回答。这对结构化输出 和特定风格特别有效:
css
const fewShotTemplate = ChatPromptTemplate.fromMessages([ ['system', '你是一个情感分析助手,按示例格式输出。'],
// Few-shot 示例(用户-助手对话对)
['human', '这个产品太棒了!'],
['ai', '{"sentiment": "positive", "score": 0.95}'],
['human', '服务态度很差'],
['ai', '{"sentiment": "negative", "score": 0.88}'],
// 真正的问题
['human', '{input}'],
]);
const messages = await fewShotTemplate.formatMessages({
input: '还行吧,一般般',
});
// 模型会模仿前面的格式输出:{"sentiment": "neutral", "score": 0.5}
System Prompt 编写技巧
System message 决定了 Agent 的"人格"和能力边界。一个好的 system prompt 通常包含5 个要素:
| 要素 | 作用 | 错误示例 | 正确示例 |
|---|---|---|---|
| 角色设定 | 给模型一个明确的"身份" | "你是一个助手" | "你是一个 10 年经验的 Python 后端架构师,擅长性能优化" |
| 能力边界 | 告诉模型能做什么不能做什么 | (不提) | "只回答技术问题,不要讨论政治、宗教" |
| 输出格式 | 约束回答结构 | (不提) | "回答用 Markdown,先结论后论据,不超过 500 字" |
| 风格锚定 | 设定语气和读者 | (不提) | "用通俗语言,面向初学者,避免专业术语" |
| 反例 | 防止常见错误 | (不提) | "不要编造 API 名;不确定时说'我不确定'而不是瞎猜" |
diff
// 一个完整的 system prompt 示例
const goodSystemPrompt = `
你是一个 Python 后端架构师,专注于 FastAPI 和 PostgreSQL。
## 能力范围
- ✅ 回答 FastAPI / SQLAlchemy / PostgreSQL 技术问题
- ✅ 评审代码并给出改进建议
- ❌ 不回答前端、移动端、运维问题
- ❌ 不讨论 Python 之外的编程语言
## 输出格式
- 用 Markdown 格式
- 代码块用 ```python 包裹
- 复杂问题先列 3 条要点,再展开
- 单次回答不超过 500 字
## 风格
- 通俗易懂,面向中级开发者
- 举具体例子而不是抽象描述
## 注意事项
- 不要编造 API 名或库名------不确定时直说
- 推荐方案时说明理由和适用场景
`.trim();
System Prompt 不是越长越好:实验表明,超过 ~2000 token 后,效果增益递减(甚至因注意力分散而下降)。把核心约束放前面,细节放后面,模型对开头的指令遵守度最高。
1.3、上下文窗口
每次调用模型,你能发送和接收的内容总量是有上限的。超过上限,请求会被拒绝或内容被截断。
历史消息
上下文窗口里最大的一块开销通常是历史消息。想象一个连续对话了 50 轮的 Agent------前 49 轮的"用户问题 + 模型回答 + 工具结果"全部要塞进 context window。如果不做任何处理,第 50 轮时可能已经 100K Token 了,逼近上限。
常见的处理策略:
| 策略 | 做法 | 适用场景 |
|---|---|---|
| 全量保留 | 所有消息原样传入 | 短对话(<10 轮),精度要求高 |
| 滑窗截断 | 只保留最近 N 轮 | 简单场景,会丢早期信息 |
| 摘要压缩 | 把旧消息压缩成一段 summary | 长对话,平衡精度与成本 |
| 检索式 | 把旧消息存向量库,按需检索 | 超长对话(数百轮) |
生产环境的常见做法是摘要压缩 + 滑窗混合:当 token 占用超过 75% 时,触发一个短期记忆管理器把早期对话压缩成一段 summary 注入上下文。
Token 用量
模型每次调用返回时,都会附上一份 usage 报告,告诉你这次用了多少 Token。这是唯一可信的 Token 计数来源------不要自己估算,要以模型返回的为准。
javascript
import { ChatAnthropic } from '@langchain/anthropic';
const model = new ChatAnthropic({ model: 'claude-sonnet-4-20250514' });
const response = await model.invoke([
new HumanMessage({ content: '你好' }),
]);
// LangChain 1.x:usage 在 response.usage_metadata 里
console.log(response.usage_metadata);
// {
// input_tokens: 12,
// output_tokens: 8,
// total_tokens: 20,
// input_token_details: { cache_read: 0, cache_creation: 1024 },
// output_token_details: { reasoning: 0 }
// }
⚠️ 注意 Anthropic 的 input_tokens 已包含 cache_read 部分 。如果你直接 input_tokens * 单价,会把 cache 命中部分按全价算,导致重复计费。正确算法:(input_tokens - cache_read) * input_price + cache_read * cache_read_price。
缓存机制(Anthropic Prompt Caching)
Anthropic 提供 Prompt Cache ------把一段稳定的 prompt(如 system 指令、长文档、工具定义)标记为 cache,后续请求如果前缀匹配 就直接复用缓存,按 1/10 单价计费。对长上下文场景(如带长 system prompt + 历史对话)能省 80%+ 成本。
javascript
import { ChatAnthropic } from '@langchain/anthropic';
import { SystemMessage, HumanMessage } from '@langchain/core/messages';
// ① 长 system prompt(假设 5000 token,每次调用都会带)
const longSystemPrompt = `
你是 Apollo AI 助手。下面是完整的产品文档(5000 字):
[这里是一段很长的文档...]
`.trim();
const model = new ChatAnthropic({
model: 'claude-sonnet-4-20250514',
});
// ② 第一次调用:用 cache_control 标记这段要缓存
const firstCall = await model.invoke([
new SystemMessage({
content: longSystemPrompt,
// 关键:标记缓存断点(这里缓存前 5000 token)
additional_kwargs: { cache_control: { type: 'ephemeral' } },
}),
new HumanMessage({ content: '文档讲了什么?' }),
]);
console.log('首次用量:', firstCall.usage_metadata);
// input_tokens: 5000, cache_creation_tokens: 5000
// ③ 第二次调用:前缀相同 → 自动命中 cache
const secondCall = await model.invoke([
new SystemMessage({
content: longSystemPrompt, // 完全相同
additional_kwargs: { cache_control: { type: 'ephemeral' } },
}),
new HumanMessage({ content: '文档里有几个章节?' }), // 用户问题变了
]);
console.log('二次用量:', secondCall.usage_metadata);
// input_tokens: 5050, cache_read_tokens: 5000 ← 命中!
// 5000 个 token 按 cache_read 单价算($0.30/M),原价是 $3/M
缓存规则:
| 维度 | 规则 |
|---|---|
| 匹配方式 | 前缀匹配------前 N 个 token 完全相同才命中 |
| 缓存粒度 | 最小 1024 token,最多 4 个断点 |
| 有效期 | 5 分钟(ephemeral),过期失效 |
| 最佳实践 | 把稳定不变 的内容放前面(system、长文档),把变化的放后面(用户输入) |
什么时候用 cache :如果你有 ≥2000 token 的稳定 prompt + 同一会话多轮调用,cache 几乎必开。常见做法是封装一个 prompt builder,在 system message 长度 > 2K 时自动追加 cache_control 标记。
二、LLM 基本调用
2.1、invoke - 非流式输出
invoke 是最基础的调用方式------你传入完整的消息列表,等模型一次性 生成完整回答后返回。适合非交互场景:后台批处理、结构化数据提取、不需要实时显示进度的任务。
javascript
import { ChatAnthropic } from '@langchain/anthropic';
import { SystemMessage, HumanMessage } from '@langchain/core/messages';
// 1. 实例化模型(不同 provider 用不同的类,但 API 一致)
const model = new ChatAnthropic({
model: 'claude-sonnet-4-20250514',
temperature: 0.7,
maxTokens: 1024,
});
// 2. 构造消息列表
const messages = [
new SystemMessage({
content: '你是一个简洁的技术助手,回答不超过 3 句话。',
}),
new HumanMessage({
content: '什么是 ReAct 模式?',
}),
];
// 3. invoke:阻塞直到完整回答返回
const response = await model.invoke(messages);
console.log(response.content);
// "ReAct = Reasoning + Acting。模型先推理(Reasoning)决定下一步做什么,
// 再行动(Acting)调用工具,根据工具结果继续推理,循环直到得出最终答案。"
console.log(response.usage_metadata);
// { input_tokens: 28, output_tokens: 45, total_tokens: 73 }
不同 Provider 的实例化方式(API 一致,只换类名):
javascript
import { ChatOpenAI } from '@langchain/openai';
import { ChatOllama } from '@langchain/ollama';
// OpenAI
const openaiModel = new ChatOpenAI({ model: 'gpt-4o' });
// 本地 Ollama(零隐私外泄,本地推理)
const localModel = new ChatOllama({ model: 'qwen2.5:14b' });
invoke 的缺点 :用户要干等模型生成完才能看到任何输出。对于长回答(如 2000 字技术文档),用户可能盯着空白屏幕等 10 秒------体验差。所以面向用户的场景应该用 stream。
2.2、stream - 流式输出
stream 是增量返回 方式------模型每生成一个 Token(或一小段),就立即推送给你,你可以实时渲染到 UI。这是面向用户的交互场景的标准做法。
typescript
import { ChatAnthropic } from '@langchain/anthropic';
import { HumanMessage } from '@langchain/core/messages';
const model = new ChatAnthropic({
model: 'claude-sonnet-4-20250514',
});
// stream:返回一个 AsyncIterable,逐块产出
const stream = await model.stream([
new HumanMessage({ content: '用 200 字解释什么是上下文窗口。' }),
]);
// 用 for-await 逐块消费
for await (const chunk of stream) {
// chunk.content 是这一小块文本
process.stdout.write(chunk.content as string);
// UI 端:append 到消息气泡,实现"打字机"效果
}
console.log('\n--- 流式结束 ---');
stream 与 invoke 的本质区别:
| 维度 | invoke | stream |
|---|---|---|
| 返回方式 | 一次性返回完整 AIMessage |
逐块返回 AIMessageChunk,最后可拼成完整消息 |
| 首字延迟 | 高(等全部生成完) | 低(几十毫秒出第一个字) |
| 可中断性 | 难(只能整个请求 abort) | 易(可在 for-await 中途 break) |
| Token 用量 | 直接在 response.usage_metadata |
需要累积每个 chunk 的 usage(或在最后 chunk 取) |
⚠️ stream 模式下,usage_metadata 通常只在最后一个 chunk 才有完整值,中间 chunk 的 usage 是增量。需要在循环里累积每个 chunk 的 usage 字段(input_tokens / output_tokens),最后一次累加得到真实总量。
Agent 场景下的流式 :当模型决定调用工具时,stream 会先输出 tool_calls chunk,然后工具执行,工具结果以 ToolMessage 形式回传,模型继续 stream 最终回答。LangGraph 的 createAgent / preModelHook 机制封装了这整套流程,详见第 3 篇 Agent Tools。
php
import { createAgent } from '@langchain/langgraph';
const agent = createAgent({
llm: model,
tools: [/* ... */],
});
// Agent 流式执行:会自动处理"推理 → 调用工具 → 看结果 → 继续推理"的循环
const eventStream = agent.stream(
{ messages: [{ role: 'user', content: '帮我查北京天气并写一首诗' }] },
{ streamMode: 'updates' }, // 每个节点更新时推送
);
for await (const event of eventStream) {
console.log(event); // { agent: { messages: [...] } } 或 { tools: { messages: [...] } }
}
这样,用户就能看到"模型正在思考 → 正在调用天气工具 → 正在根据结果写诗"的全过程,而不是干等一个最终答案。
2.3、batch - 批量调用
当你有多组独立的对话 要并行处理(批处理、批量分类、批量翻译等),batch 比循环 invoke 高效得多------它会自动并发发送请求,复用底层 HTTP 连接。
javascript
import { ChatAnthropic } from '@langchain/anthropic';
import { HumanMessage } from '@langchain/core/messages';
const model = new ChatAnthropic({ model: 'claude-sonnet-4-20250514' });
// 同时处理 5 个独立的问题
const batchInputs = [
[new HumanMessage({ content: '把 "hello" 翻译成中文' })],
[new HumanMessage({ content: '把 "world" 翻译成中文' })],
[new HumanMessage({ content: '把 "good morning" 翻译成中文' })],
[new HumanMessage({ content: '把 "thank you" 翻译成中文' })],
[new HumanMessage({ content: '把 "goodbye" 翻译成中文' })],
];
// batch 并发执行,返回数组结果
const results = await model.batch(batchInputs);
results.forEach((res, i) => {
console.log(`Q${i + 1}: ${batchInputs[i][0].content} → ${res.content}`);
});
// Q1: hello → 你好
// Q2: world → 世界
// Q3: good morning → 早上好
// Q4: thank you → 谢谢
// Q5: goodbye → 再见
batch vs Promise.all(invoke) :
| 维度 | Promise.all(inputs.map(invoke)) |
model.batch(inputs) |
|---|---|---|
| 并发控制 | 完全无限制,可能打爆 rate limit | LangChain 内部带并发限制(可配置 maxConcurrency) |
| 错误处理 | 一个失败全部 reject | 可配 returnExceptions: true,单条失败不阻塞其他 |
| 资源复用 | 每次 invoke 都新建 HTTP 连接 | 复用底层连接池 |
| 适用 | 调用次数少(<10) | 大量调用、批处理场景 |
javascript
// 控制并发数(避免触发 Provider 限流)
const limitedBatch = model.withConfig({
maxConcurrency: 3, // 同时最多 3 个请求
});
// 容错模式:单条失败不阻塞其他
const safeResults = await model.batch(batchInputs, {
returnExceptions: true,
});
safeResults.forEach((r, i) => {
if (r instanceof Error) {
console.error(`Q${i + 1} 失败:`, r.message);
} else {
console.log(`Q${i + 1}: ${r.content}`);
}
});
一个典型应用:批量翻译文档段落时用 batch 模式,比逐条 invoke 快 5-10 倍(因为复用了底层 HTTP 连接池,且 LangChain 自带并发限制保护)。
2.4、错误重试与超时
LLM 调用可能因网络抖动、rate limit、Provider 临时故障失败。生产代码必须做重试 + 超时控制:
typescript
import { ChatAnthropic } from '@langchain/anthropic';
// ① 模型实例化时配置超时
const model = new ChatAnthropic({
model: 'claude-sonnet-4-20250514',
timeout: 30_000, // 单次请求 30 秒超时
maxRetries: 3, // 失败自动重试 3 次
});
// ② 用 AbortController 中断长任务(特别是流式)
const controller = new AbortController();
setTimeout(() => controller.abort(), 60_000); // 60 秒强制中断
try {
const stream = await model.stream(
[{ role: 'user', content: '写一篇 5000 字的小说' }],
{ signal: controller.signal },
);
for await (const chunk of stream) {
process.stdout.write(chunk.content as string);
}
} catch (err) {
if (err.name === 'AbortError') {
console.log('用户主动中断');
} else {
console.error('调用失败:', err);
}
}
// ③ 自定义重试策略(指数退避)
import { RunnableRetry } from '@langchain/core/runnables';
const retryModel = new RunnableRetry({
bound: model,
maxAttempts: 5,
// 指数退避:1s, 2s, 4s, 8s, 16s
backoffFactor: 2,
initialDelayMs: 1000,
// 只对特定错误重试(不重试业务错误)
retryOnError: (err) => err.message.includes('rate_limit') || err.message.includes('timeout'),
});
关键原则 :重试只针对临时性错误(rate limit、超时、5xx),不要重试业务错误(参数错、内容违规)。常见的 retry wrapper 实现对 429/5xx 指数退避、对 4xx 直接失败。