做AI Agent先算账:Token成本、缓存、消息规范一次性讲清
P.S. 推荐一个大神的教程给想要了解或者学习人工智能知识的读者,这个教程里内容讲解通俗易懂且风趣幽默,对我帮助很大。我想与大家分享这个宝藏教程,请点击下方链接查看, 传送门https://blog.csdn.net/qq_74013365
前言
做前端AI Agent的同学,我敢说80%的人第一个坑,都栽在Token上。
刚上手的时候,以为就是调个API发句话收个结果,跟聊天机器人似的。直到月底收到云服务商的账单,人都傻了:我没发几句话啊,怎么钱没了?
今天就把Agent API调用最基础的东西给你扒明白,从算账到调用姿势,全是踩坑踩出来的干货。
1 先算明白账:Token到底是啥
1.1 别再按“字数”算钱了
很多人有个误区:我发100个字,就收100个字的钱?大错特错。
LLM计费的单位叫Token,翻译过来叫词元。你可以理解成模型把文字切碎后的最小语义块——不是一个字一块,也不是一个词一块,是模型自己切的。
比如英文单词hamburger,模型能给你切成ham+bur+ger三块;中文更亏,大概两个汉字才算一个Token。粗略算下来,1个Token≈4个英文字符≈0.5个汉字。
这就像你去买切糕,你以为按斤称,结果人家按“块”算,一刀下去你半个月工资没了。
1.2 Token计费的四个门道
别以为只有一种计费,就拿Claude举例,光Token类型就分四种,价格差出10倍去。
| Token类型 | 啥意思 | 参考单价 |
|---|---|---|
| input_tokens | 你发给模型的所有内容,系统提示+历史+问题 | $3 / 1M |
| output_tokens | 模型生成的回答,连思考过程也算 | $15 / 1M |
| cache_read_tokens | 命中缓存的部分,白菜价 | $0.3 / 1M |
| cache_creation_tokens | 第一次写入缓存的部分 | $3.75 / 1M |
划重点:输出Token通常比输入贵5倍左右。所以让模型“少废话、说重点”,比你少发两行prompt省钱多了。
这也是为啥所有Agent系统都死卡输出格式,非要JSON、非要简短——不是产品经理强迫症,是真金白银在烧。
1.3 你的Token预算怎么算
模型的上下文窗口是有限的,就像手机运行内存,不是你想塞多少就塞多少。
一次Agent调用,Token开销大概是这么几部分:
总Token预算 = 上下文窗口上限
= 系统提示词(固定开销,一般1-5K)
+ 历史对话(越聊越多,1-50K都有可能)
+ 当前用户问题
+ RAG检索出来的文档片段
+ 预留模型输出的空间
+ 模型思考过程占用
+ 工具调用返回的结果
当总用量冲到窗口的75%-90%,就得赶紧压缩上下文了。不然要么模型直接失忆,前面说的全忘光;要么直接给你报错,请求发不出去。
实战里一般会写个监控器,每轮对话开始前先算一遍当前Token数,超了就把早期对话压缩成摘要。别嫌麻烦,等线上出问题再改,账单都够你喝好几杯奶茶了。
估算Token数可以用LangChain的TokenTextSplitter,别自己瞎数,数不准的。
import { TokenTextSplitter } from '@langchain/textsplitters';
const splitter = new TokenTextSplitter({
chunkSize: 1000,
chunkOverlap: 0,
});
const text = '这是一段用来测试Token数量的中文文本,帮你大概估算上下文占用。';
const chunks = await splitter.splitText(text);
console.log(`约 ${chunks.length} 个分块,每块上限1000 Token`);
2 Prompt不是写作文,是给模型发说明书
很多人写prompt的最大问题:太客气了。
“你好呀,能不能帮我看看这段代码有什么问题呀,谢谢~”
模型根本不吃这一套。它没有感情,只会根据上下文预测下一个Token。你不说清楚身份、格式、边界,它就给你瞎输出。
2.1 四个角色别搞混
在LangChain的体系里,一次对话是一堆消息组成的列表,每条消息都有自己的角色。一共四个核心角色,各司其职。
| 角色 | 谁发的 | 干啥用的 | 对应类名 |
|---|---|---|---|
| system | 开发者 | 定人设、定规则、定边界 | SystemMessage |
| user | 用户 | 提问题、发任务 | HumanMessage |
| assistant | 模型 | 输出回答、调用工具 | AIMessage |
| tool | 工具 | 返回工具执行结果 | ToolMessage |
打个比方:system是公司老板,给你定岗位职责;user是客户,提需求;assistant是你干活的;tool是你用的办公软件。
这里有个巨容易踩的坑:ToolMessage的tool_call_id,必须和之前AIMessage里的调用id一一对齐。
就像你拿快递,快递单号不对,你都不知道这是谁的包裹。对不齐的话,模型直接混乱,给你输出各种莫名其妙的东西。
import {
SystemMessage,
HumanMessage,
AIMessage,
ToolMessage,
} from '@langchain/core/messages';
const messages = [
new SystemMessage({
content: '你是一个严格输出JSON格式的助手,禁止多余文字。',
}),
new HumanMessage({
content: '北京今天天气怎么样?',
}),
new AIMessage({
content: '',
tool_calls: [
{
name: 'get_weather',
args: { city: 'Beijing' },
id: 'call_001',
type: 'tool_call',
},
],
}),
new ToolMessage({
content: '{"temp": 22, "condition": "sunny"}',
tool_call_id: 'call_001',
}),
];
2.2 别手写消息列表,用模板
如果每次调用都手动拼消息列表,那代码写出来又臭又长,变量散得到处都是,改都没法改。
工程化的做法是用ChatPromptTemplate,把prompt参数化、模板化,像填空一样用。
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { ChatAnthropic } from '@langchain/anthropic';
// 定义模板,用变量占位
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留位置,动态塞历史对话、示例、检索结果进去,非常灵活。
import { MessagesPlaceholder } from '@langchain/core/prompts';
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里塞几个“问题→标准答案”的例子,模型会乖乖照着格式输出,比你说一百句“请输出JSON”都管用。
2.3 写好System Prompt的五要素
System Prompt直接决定了你的Agent聪不聪明、守不守规矩。很多人写system就一句话:“你是一个助手”——那模型可不就瞎来嘛。
一个合格的system prompt,得包含这五件事:
| 要素 | 作用 | 反面教材 | 正面例子 |
|---|---|---|---|
| 角色设定 | 给模型明确身份 | “你是一个助手” | “你是10年经验的Python后端架构师,擅长性能优化” |
| 能力边界 | 说清能做啥不能做啥 | (啥都不说) | “只回答技术问题,不讨论无关话题” |
| 输出格式 | 约束回答结构 | (啥都不说) | “用Markdown,先结论后论据,不超过500字” |
| 风格锚定 | 定语气和受众 | (啥都不说) | “通俗语言,面向初学者,避免黑话” |
| 反例提醒 | 防止常见错误 | (啥都不说) | “不确定就说不确定,不要编造API” |
给你们看个完整的例子,照着套就行:
const goodSystemPrompt = `
你是一个Python后端架构师,专注于FastAPI和PostgreSQL。
## 能力范围
- ✅ 回答FastAPI / SQLAlchemy / PostgreSQL技术问题
- ✅ 评审代码并给出改进建议
- ❌ 不回答前端、移动端、运维问题
- ❌ 不讨论Python之外的编程语言
## 输出格式
- 用Markdown格式
- 代码块用\`\`\`python包裹
- 复杂问题先列3条要点,再展开
- 单次回答不超过500字
## 风格
- 通俗易懂,面向中级开发者
- 举具体例子而不是抽象描述
## 注意事项
- 不要编造API名或库名——不确定时直说
- 推荐方案时说明理由和适用场景
`.trim();
最后提醒一句:system prompt不是越长越好。超过2000Token之后,效果反而会下降,模型注意力分散了。核心约束放最前面,细节放后面,模型对开头的指令记得最牢。
3 上下文窗口不是无限内存
别以为模型上下文窗口越大越好,200K、1M听起来很厉害,你真敢全塞满?先不说钱的问题,塞太满模型直接“老年痴呆”,前面的内容全忘光。
3.1 历史消息的四种处理策略
上下文窗口里占比最大的,就是历史消息。聊个几十轮,轻轻松松上百K Token。常见的处理方式有四种,各有适用场景。
| 策略 | 怎么做 | 适合啥场景 |
|---|---|---|
| 全量保留 | 所有消息原封不动传进去 | 10轮以内短对话,精度要求高 |
| 滑窗截断 | 只保留最近N轮 | 简单任务,丢早期信息也无所谓 |
| 摘要压缩 | 把旧消息压缩成一段总结 | 长对话,平衡精度和成本 |
| 检索式 | 旧消息存向量库,按需检索 | 几百轮的超长对话 |
生产环境一般都是混合来:滑窗+摘要压缩。Token占用超过75%就触发压缩,把早期对话揉成一段摘要放进去,既省空间又不会完全失忆。
3.2 Token用量以模型返回为准
别自己数Token数,数不准的。模型每次调用返回的结果里,都会带一份usage报告,这才是唯一可信的计费依据。
import { ChatAnthropic } from '@langchain/anthropic';
const model = new ChatAnthropic({ model: 'claude-sonnet-4-20250514' });
const response = await model.invoke([
new HumanMessage({ content: '你好' }),
]);
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乘以单价,就等于把缓存部分按原价算了,多花冤枉钱。
正确算法是:(input_tokens - cache_read) × 输入单价 + cache_read × 缓存单价。别小看这点差异,量大了能差出好几倍。
3.3 用好缓存,省80%成本
Anthropic有个Prompt Cache功能,简单说就是:把固定不变的prompt(比如system指令、长文档、工具定义)标记成缓存,后续调用只要前缀一样,就直接复用,价格是原价的十分之一。
长上下文场景下,用好了能省80%以上的钱,相当于打两折。
import { ChatAnthropic } from '@langchain/anthropic';
import { SystemMessage, HumanMessage } from '@langchain/core/messages';
const longSystemPrompt = `
你是Apollo AI助手。下面是完整的产品文档(5000字):
[长文档内容...]
`.trim();
const model = new ChatAnthropic({
model: 'claude-sonnet-4-20250514',
});
// 第一次调用:标记这段要缓存
const firstCall = await model.invoke([
new SystemMessage({
content: longSystemPrompt,
additional_kwargs: { cache_control: { type: 'ephemeral' } },
}),
new HumanMessage({ content: '文档讲了什么?' }),
]);
// 第二次调用:前缀相同,自动命中缓存
const secondCall = await model.invoke([
new SystemMessage({
content: longSystemPrompt,
additional_kwargs: { cache_control: { type: 'ephemeral' } },
}),
new HumanMessage({ content: '文档里有几个章节?' }),
]);
缓存的规则也很简单:前缀匹配,前面的Token完全一样才命中;最小1024Token,最多设4个断点;有效期5分钟。
最佳实践就是把稳定不变的内容放最前面,经常变的用户输入放后面。只要你的system prompt超过2K,而且同一会话会调用多次,缓存必开,不开就是跟钱过不去。
4 LLM调用的四种姿势,别只会用invoke
很多人学LLM调用,学会一个invoke就用到死。不同场景适合不同的调用方式,用错了要么体验差,要么效率低。
4.1 invoke:一次性返回,适合后台任务
invoke是最基础的调用方式:你把消息列表发过去,等着模型生成完,一次性把完整结果返回给你。
import { ChatAnthropic } from '@langchain/anthropic';
import { SystemMessage, HumanMessage } from '@langchain/core/messages';
const model = new ChatAnthropic({
model: 'claude-sonnet-4-20250514',
temperature: 0.7,
maxTokens: 1024,
});
const messages = [
new SystemMessage({
content: '你是一个简洁的技术助手,回答不超过3句话。',
}),
new HumanMessage({
content: '什么是ReAct模式?',
}),
];
const response = await model.invoke(messages);
console.log(response.content);
console.log(response.usage_metadata);
优点是简单,缺点是慢。用户要等全部生成完才能看到内容,回答长一点的话,用户盯着空白屏幕等十几秒,早就走了。
所以invoke只适合后台批处理、结构化数据提取这种不需要用户盯着的场景。面向用户的交互,一律用流式。
不同厂商的模型API都是一致的,换个类名就行:
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' });
4.2 stream:流式输出,交互场景标配
stream就是增量返回,模型生成一点就推一点,你可以实时渲染到界面上,就是大家常见的“打字机”效果。
这是所有面向用户的聊天场景的标准做法,首字延迟几十毫秒,用户体验直接拉满。
import { ChatAnthropic } from '@langchain/anthropic';
import { HumanMessage } from '@langchain/core/messages';
const model = new ChatAnthropic({
model: 'claude-sonnet-4-20250514',
});
const stream = await model.stream([
new HumanMessage({ content: '用200字解释什么是上下文窗口。' }),
]);
for await (const chunk of stream) {
process.stdout.write(chunk.content as string);
}
stream和invoke的区别,给你们列清楚:
| 维度 | invoke | stream |
|---|---|---|
| 返回方式 | 一次性返回完整消息 | 逐块返回,最后拼成完整消息 |
| 首字延迟 | 高,等全部生成 | 低,几十毫秒出字 |
| 可中断性 | 难,只能整个取消 | 易,中途随时可以停 |
| Token用量 | 直接在结果里拿 | 要累积每个块的用量 |
注意:stream模式下,usage_metadata一般只有最后一个块才有完整值。要自己在循环里累加,别拿中间块的数当最终结果,那是不准的。
Agent场景下的流式更复杂,模型会先输出调用工具的指令,等工具执行完返回结果,再继续生成最终回答。LangGraph已经把这套流程封装好了,直接用就行。
import { createAgent } from '@langchain/langgraph';
const agent = createAgent({
llm: model,
tools: [/* 工具列表 */],
});
const eventStream = agent.stream(
{ messages: [{ role: 'user', content: '帮我查北京天气并写一首诗' }] },
{ streamMode: 'updates' },
);
for await (const event of eventStream) {
console.log(event);
}
这样用户就能看到完整过程:模型在思考→调用天气工具→根据结果写诗,而不是傻等半天弹出一个最终答案。
4.3 batch:批量调用,效率提升10倍
如果你有一堆独立的任务要处理,比如批量翻译、批量分类、批量提取信息,别用循环一个个invoke,慢死了。
用batch模式,它会自动并发请求,复用底层HTTP连接,比循环调用快5到10倍。
import { ChatAnthropic } from '@langchain/anthropic';
import { HumanMessage } from '@langchain/core/messages';
const model = new ChatAnthropic({ model: 'claude-sonnet-4-20250514' });
const batchInputs = [
[new HumanMessage({ content: '把 "hello" 翻译成中文' })],
[new HumanMessage({ content: '把 "world" 翻译成中文' })],
[new HumanMessage({ content: '把 "good morning" 翻译成中文' })],
[new HumanMessage({ content: '把 "thank you" 翻译成中文' })],
[new HumanMessage({ content: '把 "goodbye" 翻译成中文' })],
];
const results = await model.batch(batchInputs);
results.forEach((res, i) => {
console.log(`Q${i + 1}: ${batchInputs[i][0].content} → ${res.content}`);
});
有人说那我用Promise.all不行吗?还真不行。
| 维度 | Promise.all | model.batch |
|---|---|---|
| 并发控制 | 完全没限制,容易触发限流 | 内置并发限制,可配置 |
| 错误处理 | 一个失败全部失败 | 可配置单条失败不影响其他 |
| 资源复用 | 每次新建连接 | 复用连接池 |
| 适用 | 少量调用 | 大批量处理 |
批量处理的时候,记得控制并发数,别把服务商的接口打爆了。还有开启容错模式,单条失败别影响整体。
// 控制并发数
const limitedBatch = model.withConfig({
maxConcurrency: 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}: ${batchInputs[i][0].content} → ${r.content}`);
}
});
4.4 错误重试与超时,生产环境底线
别以为调用LLM会次次成功。网络波动、限流、服务商临时故障,都是家常便饭。生产代码不做重试和超时,线上必出问题。
最简单的方式,实例化模型的时候直接配超时和重试次数:
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);
}
}
如果要自定义重试策略,比如指数退避、只对特定错误重试,可以用RunnableRetry包装一层。
import { RunnableRetry } from '@langchain/core/runnables';
const retryModel = new RunnableRetry({
bound: model,
maxAttempts: 5,
backoffFactor: 2, // 指数退避:1s, 2s, 4s, 8s, 16s
initialDelayMs: 1000,
// 只对限流和超时重试,业务错误不重试
retryOnError: (err) =>
err.message.includes('rate_limit') || err.message.includes('timeout'),
});
核心原则:只对临时性错误重试,比如限流、超时、5xx错误。参数错误、内容违规这种业务错误,重试一百次也没用,别浪费资源。
以上就是Agent API调用最基础的部分,看起来都是细节,但真正踩坑的时候,全是这些不起眼的地方。
先搞懂Token怎么算账,再写好Prompt,选对调用方式,做好容错,你的Agent基本就稳了。
下一篇咱们聊工具调用,那才是Agent真正的灵魂。
P.S. 推荐一个大神的教程给想要了解或者学习人工智能知识的读者,这个教程里内容讲解通俗易懂且风趣幽默,对我帮助很大。我想与大家分享这个宝藏教程,请点击下方链接查看,传送门https://blog.csdn.net/qq_74013365
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐



所有评论(0)