前端转型 Agent 开发 01 之 Agent API 调用(和 Agent 的基础对话)

363 阅读4分钟

image.png

一、基础概念

1.1、Token(词元)

Token 成本

调用 LLM 时,计费单位是 Token——模型把文本切分后的最小语义单元,粗略地讲就是一个词或词根。hamburger 会被拆成 hamburger 三个 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',
  }),
];

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

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

本项目在 engine/src/tools/batch-translate.ts 用 batch 模式批量翻译文档段落,比逐条 invoke 快 5-10 倍。

2.4、bindTools - 把工具绑定到模型

invokestream 默认让模型"纯聊天"。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…