一、基础概念
1.1、Token(词元)
Token 成本
调用 LLM 时,计费单位是 Token——模型把文本切分后的最小语义单元,粗略地讲就是一个词或词根。hamburger 会被拆成 ham、bur、ger 三个 Token,而 the 通常只占 1 个。
经验换算:1 个 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 预算"的心智模型。上下文窗口有限,每次调用前都要在心里盘算各部分占了多少:
当总 Token 逼近 context_window 的 75%~90% 时,就要触发上下文压缩(总结历史、丢弃无关片段),否则模型会遗忘早期信息,或者直接报错。
实际估算 Token 数可以用 LangChain 的 tokenizer:
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 运行循环里,每一轮开始前先算一次当前上下文已用的 Token,超过阈值就压缩。本项目 engine/src/memory/short-term-controller.ts 就是这么做的,细节见第 6 篇 Memory 文档。
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:
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 参数化、模板化、可组合:
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 检索结果动态塞进去:
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 里塞几个"问题 → 标准答案"的示例,模型会模仿示例的格式和风格回答,对结构化输出和特定风格尤其有效:
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 名;不确定时说'我不确定'而不是瞎猜" |
// 一个完整的 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%,ShortTermMemoryController 把早期对话压缩成一段 summary,详见 engine/src/memory/short-term-controller.ts。
Token 用量
模型每次调用返回时都会附一份 usage 报告,这是唯一可信的 Token 计数来源——不要自己估算,以模型返回的为准。
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。本项目 engine/src/tools/usage.ts 已处理这个细节。
缓存机制(Anthropic Prompt Caching)
Anthropic 提供 Prompt Cache——把一段稳定的 prompt(system 指令、长文档、工具定义)标记为 cache,后续请求如果前缀匹配就直接复用缓存,按 1/10 单价计费。对长上下文场景(带长 system prompt + 历史对话)能省 80% 以上成本。
import { ChatAnthropic } from '@langchain/anthropic';
import { SystemMessage, HumanMessage } from '@langchain/core/messages';
// ① 长 system prompt(假设 5000 token,每次调用都会带)
const longSystemPrompt = `
你是 Vanguard 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、长文档),变化的内容放后面(用户输入) |
缓存生效的前提是有足够大的稳定前缀。如果你有 ≥2000 token 的稳定 prompt,且同一会话多轮调用,缓存几乎必开。本项目 engine/src/agents/prompt-cache.ts 在 system message 长度超过 2K 时自动启用 cache_control。
二、LLM 基本调用
2.1、invoke - 非流式输出
invoke 是最基础的调用方式:传入完整的消息列表,等模型一次性生成完整回答后返回。适合非交互场景——后台批处理、结构化数据提取、不需要实时显示进度的任务。
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 的实例化方式:
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。这是面向用户的交互场景的标准做法。
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 是增量。本项目 engine/src/tools/usage.ts 里有累积逻辑。
Agent 场景下的流式:当模型决定调用工具时,stream 会先输出 tool_calls chunk,然后工具执行,工具结果以 ToolMessage 形式回传,模型继续 stream 最终回答。LangGraph 的 createAgent / preModelHook 机制封装了这整套流程,详见第 3 篇 Agent Tools。
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 连接。
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 与 Promise.all(inputs.map(invoke)) 的差异:
| 维度 | Promise.all(inputs.map(invoke)) | model.batch(inputs) |
|---|---|---|
| 并发控制 | 完全无限制,可能打爆 rate limit | LangChain 内部带并发限制(可配置 maxConcurrency) |
| 错误处理 | 一个失败全部 reject | 可配 returnExceptions: true,单条失败不阻塞其他 |
| 资源复用 | 每次 invoke 都新建 HTTP 连接 | 复用底层连接池 |
| 适用 | 调用次数少(<10) | 大量调用、批处理场景 |
// 控制并发数(避免触发 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}`);
}
});
本项目在 engine/src/tools/batch-translate.ts 用 batch 模式批量翻译文档段落,比逐条 invoke 快 5-10 倍。
2.4、bindTools - 把工具绑定到模型
invoke 和 stream 默认让模型"纯聊天"。Agent 场景下模型需要知道有哪些工具可用——这是 bindTools 的职责。它返回一个新的模型实例,该实例的调用会自动带上工具声明。
import { ChatAnthropic } from '@langchain/anthropic';
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
const getWeather = tool(
async ({ city }) => `${city}:晴,25°C`,
{
name: 'get_weather',
description: '查询指定城市的天气',
schema: z.object({ city: z.string() }),
},
);
const model = new ChatAnthropic({ model: 'claude-sonnet-4-20250514' });
// 绑定工具——返回一个新 model 实例
const modelWithTools = model.bindTools([getWeather]);
// 调用时模型会"看到"工具,可以决定调用
const response = await modelWithTools.invoke([
{ role: 'user', content: '北京天气怎么样?' },
]);
// 如果模型决定调工具,response.tool_calls 会有内容
console.log(response.tool_calls);
// [{ name: 'get_weather', args: { city: '北京' }, id: 'call_xxx', type: 'tool_call' }]
bindTools 只是把工具"宣告"给模型,不会自动执行工具——拿到 tool_calls 后还要自己解析、调工具、回传结果。createAgent(第 3 篇文档)封装了完整循环:"宣告 → 调 → 回传 → 继续推理"。简单场景用 bindTools,复杂 Agent 用 createAgent。
2.5、错误重试与超时
LLM 调用可能因网络抖动、rate limit、Provider 临时故障而失败。生产代码必须做重试 + 超时控制:
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),业务错误(参数错、内容违规)不该重试,否则只会反复撞墙。本项目 engine/src/agents/llm-retry.ts 实现了带错误分类的重试器,对 429/5xx 指数退避、对 4xx 直接失败。
参考资料:
Agent 开发系列文章
前端转型 Agent 开发 01 之 Agent API 调用(和 Agent 的基础对话):juejin.cn/post/767744…
前端转型 Agent 开发 02 之 Provider 与 Structured Output(规范化模型输入输出):juejin.cn/post/767745…
前端转型 Agent 开发 03 之 Agent Tools(给 Agent 装上手脚)介绍 Tool Calling:juejin.cn/post/768007…