在这里插入图片描述
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

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐