【Agent精讲】调一个LLMAPI背后的工程问题

部分内容由豆包辅助生成

一个 AI Agent,大概率会从"调通一个 LLM API"开始。这件事看起来简单------发个 HTTP POST,拿个 JSON 回来,完事。

但真正把它做成一个能跑多轮对话、能接工具调用、能流式输出的 Agent 基础设施时,你会发现里面藏着不少工程上的坑。

博客原文地址:SxuanApex博客:调一个LLMAPI背后的工程问题


一、Messages API

先看一个最基础的请求。用 curl 直接打 Anthropic 的 Messages API:

bash 复制代码
curl https://api.anthropic.com/v1/messages \
    -H 'Content-Type: application/json' \
    -H 'anthropic-version: 2023-06-01' \
    -H "X-Api-Key: $ANTHROPIC_API_KEY" \
    -d '{
          "max_tokens": 1024,
          "messages": [
            {
              "content": "Hello, world",
              "role": "user"
            }
          ],
          "model": "claude-sonnet-4-6"
        }'

响应(省略了部分字段)长这样:

json 复制代码
{
  "id": "msg_013Zva2CMHLNnXjNJJKqJ2EF",
  "type": "message",
  "role": "assistant",
  "content": [
    {
      "type": "text",
      "text": "Hello! How can I help you today?"
    }
  ],
  "model": "claude-sonnet-4-6",
  "stop_reason": "end_turn",
  "usage": {
    "input_tokens": 10,
    "output_tokens": 12
  }
}

这个简单的 JSON 里有两个值得琢磨的细节。

1.1 user 和 assistant 必须交替

请求里的 messages 是一个数组,每条消息有 rolecontent 两个字段。role 只有两个值:user(用户)和 assistant(助手)。

Claude 是按 user/assistant 交替对话的模式训练的,所以 messages 数组里最好保持两个角色交替出现,第一条通常是 user。

API 不会因为连续两条 user 就报错------它会自动合并。但连续两条 assistant 会直接被拒绝

json 复制代码
{
  "type": "error",
  "error": {
    "type": "invalid_request_error",
    "message": "messages: roles must alternate between \"user\" and \"assistant\", but found multiple \"assistant\" roles in a row"
  }
}

这个坑在实现工具调用时一定会踩到。LLM 返回一个工具调用请求,这算 assistant 消息。

你执行完工具拿到结果,需要把结果作为 user 消息发回去------因为从 API 视角看,所有你发给模型的东西都归 user。如果搞错了角色,把工具结果也当 assistant 发出去,就会触发上面的报错。

1.2 响应的 content 永远是数组

注意响应里的 content 字段------它永远是一个数组,不是字符串。

虽然发送请求时 content 可以用字符串简写,但响应里永远是数组格式

为什么是数组?因为模型的一次回复可能包含多种内容:先说一段话,然后请求调用一个工具,甚至一次调多个工具。

每种内容是一个独立的 content block,类型可能是 texttool_usethinking 等。

现在你只会看到 type: "text" 的内容块。但到实现工具系统时你就会遇到 tool_use

如果忘了 content 是数组,代码大概率会出 bug。


二、流式响应

前面的 curl 示例用的是普通请求:模型生成完所有内容,一次性返回。

调试时没问题,产品里完全不能用。

原因很简单:Claude 生成一段长回复可能需要 10 到 30 秒 。用户盯着空白屏幕等 30 秒?用户体验不太友好,所以必须用流式响应

模型一边生成一边推送,你一边收到一边显示,用户看到字一个一个蹦出来,就像有人在实时打字。

2.1 SSE 事件序列

流式响应基于 SSE(Server-Sent Events)协议,本质是一个长连接的 HTTP 响应,服务器往里面持续写数据。Claude 的流式事件有固定顺序:

plaintext 复制代码
message_start          整个响应开始,带着 input_tokens 信息
  └─ content_block_start   一个内容块开始(文本或工具调用)
       └─ content_block_delta  内容增量,文字一个词一个词地到达
  └─ content_block_stop    一个内容块结束
message_delta            消息级别的增量(output_tokens、停止原因)
message_stop             整个响应结束

你的代码需要在不同事件上做不同的事:

  • message_start 到达时记录输入 Token 数

  • content_block_delta 每来一个就把文字增量推给 UI 显示

  • message_delta 里提取输出 Token 数和停止原因

  • message_stop 做收尾

容易踩的坑:一次响应可能有多个 content_block。比如模型先输出一段文字,再请求调用一个工具,这就是两个 block。解析代码不能假设只有一个文本块,要留好扩展空间。


三、一个请求里的三个抽屉

Claude API 的请求里有三个不同的信息字段:

字段 放什么 变化频率
system 角色设定、环境信息(工作目录、操作系统、当前时间) 一次会话内相对固定
messages 对话历史、动态上下文、工具调用结果 每轮都变
tools 工具描述(名称、参数 schema、返回格式) Agent 能力集,通常不变

把三个字段放在一起,一个完整的 API 请求长这样:

json 复制代码
{
  "model": "claude-sonnet-4-6",
  "max_tokens": 4096,
  "system": "你是 SxuanCode,一个终端环境中的 AI 编程助手。\n\n# Environment\n当前工作目录: /home/dev/myproject\n操作系统: Linux\n当前时间: 2026-05-27",
  "messages": [
    {"role": "user", "content": "帮我读一下 app.py 的内容"},
    {"role": "assistant", "content": "好的,我来读取 app.py 的内容。\ndef main():\n    print(\"hello\")\n\nif __name__ == \"__main__\":\n    main()"},
    {"role": "user", "content": "这个文件里有什么函数?"}
  ],
  "tools": [
    {
      "name": "read_file",
      "description": "读取指定路径的文件内容",
      "input_schema": {
        "type": "object",
        "properties": {
          "path": {"type": "string", "description": "文件路径"}
        },
        "required": ["path"]
      }
    }
  ]
}

system 里放角色设定和环境信息------你总不希望模型在 Linux 上给你建议用 PowerShell 吧。

messages 是正常的对话历史,user 和 assistant 交替出现。tools 里声明 Agent 能用的工具,这就是 Function Calling 的入口。


四、Token

Token 是 LLM 的计费单位,一般各家都会有自家的分词器Tokenizer,而且国外的比如Anthropic还会对中文征收中文税(同一个Prompt,用中文会更耗费Token)

一般来说:英文每个单词大约 1-2 个 token,中文每个字大约 1-2 个 token。

具体取决于模型的 tokenizer,不需要精确计算,只需要知道它是衡量输入输出量和计费的基本单位。

回头看响应里的 usage 字段:

json 复制代码
"usage": {
  "input_tokens": 10,
  "output_tokens": 12
}

Claude API 的计费分两部分:input_tokens 是你发给模型的所有内容(包括 system prompt、messages 和 tools 描述),output_tokens 是模型生成的回复。

  • 输出 Token 比输入 Token 贵得多,缓存命中的计费倍率也能很大程度影响计费

但更隐蔽的成本陷阱在多轮对话。

每一轮请求,你都要把完整的对话历史 发过去。聊了 20 轮,第 21 轮请求就包含前 20 轮的所有消息,input_tokens 随对话轮次线性增长

算一笔账:假设每次请求的固定开销(system prompt、环境信息、工具描述等)是 1000 tokens,每轮用户输入 50 tokens,模型回复 500 tokens。到第 20 轮,仅 input_tokens 就是 1000 + 20 × (50 + 500) = 12000 tokens。而第 1 轮只有 1050 tokens,差了 10 倍还多。

这就是为什么要上下文压缩


五、Extended Thinking:让模型先想再说

Claude 支持 Extended Thinking,让模型在正式回复之前先进行一轮内部推理。开启后响应的 content 数组里会多一个 thinking 类型的内容块,排在 text 块之前。

对 Agent 开发来说,记住两件事:

  1. thinking 的 Token 算在 output_tokens 里,是有成本的,本质上是用钱换更准确的工具调用决策。Agent 场景下通常值得------一次准确的工具调用可以省掉好几轮纠错的开销。

  2. thinking 内容不能放进后续请求的 messages 里。维护对话历史时必须把 thinking 块过滤掉,只保留 text 和 tool_use 块发给 API,否则 API 会报错。


六、封装的核心原则:暴露领域语义,隐藏实现细节

想象一个场景:你花了两周写好了 Agent,代码里到处 import 着 Anthropic 的 SDK 类型。有一天老板说,来,换个 GPT 试试。

你打开项目一看,消息类型用的是 Anthropic 的、事件解析用的是 Anthropic 的、连错误处理都跟 Anthropic 的 API 绑死了。要改动很麻烦。

这就是为什么 LLM 客户端需要做一层封装。上层代码只认你自己定义的类型:消息、流式事件、Token 用量。底下到底调的是 Claude 还是 GPT,上层完全不关心。

6.1 配置只需四个字段

覆盖所有主流供应商,配置上只需要四个字段:

  • protocol:走哪家的 API 协议(anthropic / openai)

  • model:指定模型

  • base_url:端点地址

  • api_key:认证

6.2 封装层就是翻译官

封装层干的事情说白了就是翻译。

往外发请求时,把你的统一类型翻译成对应供应商的格式;收到响应时,再翻译回来。

用伪代码表达这个设计:

plaintext 复制代码
// 你自己定义的类型(上层代码只用这些)
Message { role, content }
StreamEvent { type, text?, usage?, error? }
Usage { inputTokens, outputTokens }

// 你的客户端(根据 protocol 分发到不同的后端实现)
class LLMClient:
    constructor(protocol, model, baseURL, apiKey)

    function streamChat(systemPrompt, messages) -> Stream<StreamEvent>:
        // 1. 把自定义 Message 转成对应供应商的格式
        // 2. 调用对应的流式 API
        // 3. 把供应商的事件转成自定义 StreamEvent
        // 4. 通过异步流返回给调用方

从调用方视角看,用起来就这么几行:

plaintext 复制代码
events = client.streamChat(systemPrompt, messages)
for event in events:
    if event.type == "text":
        print(event.text)          // 逐词打印
    if event.type == "done":
        print(event.usage)         // 显示 token 用量
    if event.type == "error":
        handleError(event.error)   // 处理错误

不管底层走的是 Anthropic 协议还是 OpenAI 协议,调用方的代码完全一样。

用户想换模型,改一下配置文件就行,代码一行不用动。


七、从单轮到多轮:无状态 API 的上下文管理

到目前为止讨论的都是单次 API 调用:发一个请求,拿一个回复,结束。但对于一个 Coding Agent 来说,多轮对话是基本能力------用户描述需求,Agent 问几个澄清问题,然后开始执行,这个过程天然就是多轮的。

那多轮对话是怎么实现的?

答案可能出乎你意料:每次调 API,把完整的对话历史发过去。就这么简单。

Claude API 没有什么会话 ID 让服务器记住之前的对话。每次你发请求,都要把从第一轮到最新一轮的所有消息打包发送。模型靠这些历史消息来理解上下文:

plaintext 复制代码
第1轮请求: [user: "写个快排"]
第2轮请求: [user: "写个快排", assistant: "好的...(完整回复)", user: "改成泛型版本"]
第3轮请求: [user: "写个快排", assistant: "好的...", user: "改成泛型版本", assistant: "...", user: "加上单测"]

你需要在客户端维护完整的消息列表,每次用户发消息、模型回复,都要记录下来。Token 消耗随对话轮数线性增长这件事前面已经算过了,后续章节会处理上下文压缩,目前用最简单的全量发送策略。


八、消息模型:为什么需要两层设计

前面定义了面向 API 的消息结构:role + content。但光这两个字段够用吗?

想想 Agent 运行过程中会产生哪些信息:用户输入、模型回复、启动时的欢迎语、API 调用失败的错误信息,后面还会有工具调用记录。这些东西的角色各不相同,光 user 和 assistant 两种根本不够分。

再想想流式接收的场景。模型的回复是一个字一个字蹦出来的,这条 assistant 消息在接收过程中算什么状态?接收完了呢?中间断了呢?一条消息从创建到结束,其实是有生命周期的。

API 层那个简单的 role + content 根本表达不了这些。所以我们需要两层消息模型

8.1 API 层 vs 内部层

维度 API 层 内部层
角色 user / assistant(2 种) user / assistant / system / tool(4 种)
标识 唯一 ID,方便定位和更新
元数据 时间戳、Token 用量、响应耗时
状态 streaming / complete / error
用途 跟 LLM 通信,保持简单干净 UI 渲染、状态管理、格式转换

最关键的是多了一个状态字段 。一条 assistant 消息刚创建时是 streaming,流式接收完毕变成 complete,出错变成 error

有了状态,UI 就能根据它决定怎么渲染。格式转换的时候也能把 error 状态的回复过滤掉,别发给 API 让模型困惑。

唯一 ID 也很关键。流式接收时,你需要根据 ID 定位到那条正在接收的 assistant 消息,不断追加文本。没有 ID,你就得靠"最后一条 assistant 消息"这种脆弱的假设来定位,后面场景一复杂就会出问题。


九、对话管理器:并发安全与格式转换

有了消息模型,接下来想一个问题:谁来管这些消息?

你可能觉得,搞一个数组往里面 append 不就行了。

但别忘了流式接收的场景:后台正在往一条 assistant 消息里追加文字,同时另一边正在读这个消息列表来渲染。两处同时操作同一个列表,不加保护就是数据竞争

所以你需要一个对话管理器,把消息列表包起来,内部保证并发安全(不同语言做法不同,有的用锁,有的靠单线程异步天然避免竞争)。外部调用方只需要:

  • 添加消息时拿到一个唯一 ID

  • 流式更新时根据 ID 追加内容

  • 需要渲染时拿一份消息列表的快照

并发的事情全交给管理器。

9.1 最关键的方法:toAPIFormat()

对话管理器里最关键的方法是 toAPIFormat():把内部层的消息列表转换成 API 层的格式。这个转换看似只是格式映射,但里面藏着不少坑。

**第一步:过滤。**内部层有些消息不该发给 API。system 角色的消息(比如欢迎语)是内部概念,API 有单独的 system prompt 参数,你再发一条 system 过去会让模型困惑。error 状态的 assistant 消息也得过滤掉------你总不希望模型看到一条报错信息然后尝试接着它说。

**第二步:合并。**虽然 Claude API 能自动合并相邻的同角色消息,但客户端主动合并是更好的做法,减少冗余 token,消息结构也更清晰,方便调试。

**第三步:交替校验。**确保首条消息是 user,并且 user/assistant 交替出现。如果过滤掉 system 消息后第一条变成了 assistant,模型可能理解不了上下文。

这个函数一定要写单元测试。空列表、只有一条消息、连续三条 user、第一条是 system,这些边界情况都得覆盖到。消息格式越规范,模型的理解越准确,后续调试也越轻松。


十、流式更新与多轮协作:把所有东西串起来

前面分别讲了流式响应和多轮对话两个概念。在真实的 Agent 里,它们要配合起来工作。整个流程的伪代码如下:

python 复制代码
function sendMessage(userText):
    // 1. 用户消息加入对话管理器
    conversation.addMessage({
        role: "user",
        content: userText,
        status: "complete"
    })

    // 2. 创建一条空的 assistant 消息,状态为 streaming
    assistantId = conversation.addMessage({
        role: "assistant",
        content: "",
        status: "streaming"
    })

    // 3. 把完整对话历史转换成 API 格式
    apiMessages = conversation.toAPIFormat()

    // 4. 异步调用 LLM,流式更新 assistant 消息
    stream = llmClient.streamChat(systemPrompt, apiMessages)
    for event in stream:
        if event.type == "text":
            conversation.updateMessage(assistantId, appendText(event.text))
        if event.type == "done":
            conversation.updateMessage(assistantId, setComplete(event.usage))
        if event.type == "error":
            conversation.updateMessage(assistantId, setError(event.error))

注意第 2 步:先创建一条空的 assistant 消息,拿到 ID,后续通过这个 ID 不断追加内容。这样不管 UI 怎么渲染,数据层的更新逻辑都是一样的。

整个多轮对话的节奏就是:用户输入 → 加入历史 → 转换格式 → 调 API → 流式更新 → 等待下一轮输入。每一轮都带着完整的对话历史,模型就记住了之前说过的话。

相关推荐
李冰然1 小时前
深入Java集合框架:ArrayList源码解析(JDK 8)
java
Uncommon.1 小时前
使用Pytorch操作张量(多维数组)
人工智能·pytorch·python
welfare9621 小时前
新号别搞:c++类与对象
开发语言·c++
码云骑士1 小时前
105-Ollama本地部署-Llama3-Qwen2-Modelfile-REST-API
python
晚安code1 小时前
MCP Server 开发入门:手把手写一个能跑的 Server,三种协议怎么选
python·ai编程
CTA量化套保1 小时前
量化脚本准备实盘了吗?TqSdk 上线前工程检查
人工智能·python
小小啊python1 小时前
IDEA中SpringBoot项目配置热部署
java·ide·intellij-idea·springboot·热部署
JavaPub-rodert1 小时前
我把 OpenAI 协议塞进了 Go 工具库:go-commons 开始支持 AI 了
开发语言·人工智能·golang
caimouse1 小时前
ReactOS 图形系统分析(23):引擎内存管理 — mem.c
c语言·开发语言