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

一、基础概念

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

⚠️ ToolMessagetool_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 直接失败。

相关推荐
threerocks1 小时前
AI 原生软件开发生命周期手册 - 如何借助 AI,逐阶段改造软件开发生命周期
前端·javascript·后端
徐小夕1 小时前
3分钟从想法到Agent上线:我们开源了一款AI可视化工作流“IDE”
前端·算法·github
咔咔学姐kk1 小时前
小白程序员必收藏:轻松入门AI Agent开发,大厂校招新风口!
人工智能·深度学习·ai·程序员·大模型·就业·大模型学习
2601_967097222 小时前
园区巡检机器人推荐:4项评估维度与主流产品横评
大数据·人工智能·信息可视化
stuartevil2 小时前
零基础怎么用AI文生漫剧做出一条完整视频
人工智能·音视频
极客猴子2 小时前
能提取抖音视频文案的APP推荐:短视频文案工具合集
人工智能·智能手机·音视频·语音识别
sel_93 小时前
【强化学习】Hands-on Modern RL项目实践|OPD 算法完整解析
人工智能·深度学习·算法·机器学习·语言模型
xushichang123_3 小时前
Agentic AI 应用 Token 开销居高不下,可借助哪些云平台与架构实现成本管控?
人工智能
世岩清上3 小时前
新能源宣传片怎么拍?如何用画面讲好你的绿色能源故事
大数据·人工智能·能源·宣传片·宣传片拍摄