前端的 AI 学习之路 01 之 Agent API 调用 - 和 Agent 的基础对话

36 阅读4分钟

一、基础概念

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)是有限的,每次调用都要在心里盘算:

总 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:

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

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',
  }),
];

⚠️ ToolMessagetool_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% 时,触发一个短期记忆管理器把早期对话压缩成一段 summary 注入上下文。

Token 用量

模型每次调用返回时,都会附上一份 usage 报告,告诉你这次用了多少 Token。这是唯一可信的 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

缓存机制(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 = `
你是 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 是最基础的调用方式——你传入完整的消息列表,等模型一次性生成完整回答后返回。适合非交互场景:后台批处理、结构化数据提取、不需要实时显示进度的任务。

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 一致,只换类名):

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 的本质区别

维度invokestream
返回方式一次性返回完整 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。

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 vs Promise.all(invoke)

维度Promise.all(inputs.map(invoke))model.batch(inputs)
并发控制完全无限制,可能打爆 rate limitLangChain 内部带并发限制(可配置 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}`);
  }
});

一个典型应用:批量翻译文档段落时用 batch 模式,比逐条 invoke 快 5-10 倍(因为复用了底层 HTTP 连接池,且 LangChain 自带并发限制保护)。

2.4、错误重试与超时

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),不要重试业务错误(参数错、内容违规)。常见的 retry wrapper 实现对 429/5xx 指数退避、对 4xx 直接失败。