API 上下文结构

大模型 API 的核心是一个消息列表,每条消息带一个角色标识。每次调用都是无状态的——模型不会记住上一次,agent 框架必须每次把完整历史送回去。理解这个结构,是掌握后续所有上下文工程技术的基础。

本节以 OpenAI 的 Chat Completions API 为例(Anthropic、Google 等厂商的 API 结构大同小异),拆解 agent 每次调用大模型时的完整请求构成。理解这个结构,是掌握后续所有上下文工程技术的基础。

消息的四种角色

大模型 API 的核心是一个消息列表(messages),列表中的每条消息都有一个角色(role)标识,模型根据角色来理解每条消息的含义和来源:

  • system:系统提示词。由开发者编写,定义 agent 的身份、行为规则、约束条件。模型将其视为最高优先级的指令。整个对话过程中通常只有一条,放在消息列表的最前面。
  • user:用户消息。来自终端用户的输入,是 agent 需要响应的请求。
  • assistant:助手消息。模型之前的回复,包括文本回复和工具调用请求。在多轮对话中,之前的 assistant 消息会被放回消息列表,让模型“记住”自己说过什么。
  • tool:工具结果。agent 框架执行工具后,将结果以 tool 角色的消息送回给模型。每条 tool 消息通过 tool_call_id 与对应的工具调用请求关联。

此外,工具定义(tools)作为请求的独立字段(而非消息),告诉模型有哪些工具可以使用、每个工具接受什么参数。

单轮对话:最简单的 API 调用

先看一个不涉及工具调用的最简单场景——用户问 “Hello, who are you?”:

// Request(agent 框架构造)
{
  "model": "Qwen3-0.6B",
  "messages": [
    { "role": "system", "content": "You are a helpful coding assistant." },  // 开发者写的规则
    { "role": "user",   "content": "Hello, who are you?" }                    // 用户输入
  ]
}
// Response(API 返回)
// { "role": "assistant", "content": "Hi! I'm a coding assistant. ..." }

这个请求只包含两条消息:一条 system 和一条 user,模型返回一条 assistant 回复。这就是大模型 API 最基本的交互模式——每次调用都是无状态的,所有模型需要的信息必须在请求的消息列表中完整提供

带工具调用的多轮交互:agent 的核心循环

真正的 agent 场景远比单轮问答复杂。当用户问 “What’s the current time and weather in Vancouver?” 时,模型无法凭自身知识回答,需要调用外部工具。第一次调用时,请求里带上 tools 字段(get_current_timeget_weather 的定义),模型返回的不是文本,而是工具调用请求

// Response(模型决定调用工具)
{
  "role": "assistant",
  "content": null,
  "tool_calls": [
    { "id": "call_abc123", "function": { "name": "get_current_time", "arguments": "{\"timezone\": \"America/Vancouver\"}" } },
    { "id": "call_def456", "function": { "name": "get_weather", "arguments": "{\"city\": \"Vancouver\", \"unit\": \"celsius\"}" } }
  ]
}

注意,模型并没有直接回答,而是返回了两个工具调用请求——它判断两者之间没有依赖关系,可以并行调用。模型只是发出了调用请求,真正执行工具的是 agent 框架。这是理解 agent 架构的关键:模型负责决策(调用什么工具、传什么参数),agent 框架负责执行(实际调用 API、运行代码)。

agent 框架拿到请求后实际执行这两个工具,然后把完整的对话历史加上工具执行结果一起发起第二次调用:

// Request(agent 框架构造,第 2 次)—— 完整历史 + 新增的 tool 结果
[
  { "role": "system", "content": "..." },                    // 与第 1 次相同
  { "role": "user", "content": "What's the current time..." },// 与第 1 次相同
  { "role": "assistant", "content": null, "tool_calls": [...] },// 第 1 次的模型输出,原样放回
  { "role": "tool", "tool_call_id": "call_abc123", "content": "{\"datetime\": \"2025-09-13T05:18:47\", ...}" },
  { "role": "tool", "tool_call_id": "call_def456", "content": "{\"temperature\": 13.2, \"conditions\": \"clear\", ...}" }
]

这里有三个关键细节:

  1. 第二次请求包含了第一次的全部对话历史——这就是“每次调用都是无状态的”:模型不会“记住”上一次的对话,agent 框架必须每次都把完整历史送回去。
  2. 第一次的 assistant 消息被原样放回消息列表——这让模型能“看到”自己之前做了什么决策。
  3. tool 消息通过 tool_call_id 与对应的工具调用关联——模型据此知道哪个结果对应哪个调用。

这一次模型不再返回 tool_calls,而是直接给出文本回复——它判断已经有了足够的信息。如果还需要更多信息,它会再次返回 tool_calls,agent 框架再执行、再送回结果,如此循环。这个“请求 → 工具调用 → 执行 → 送回结果 → 再请求”的循环,就是 ReAct 循环在 API 层面的具体实现。

从 API 视角看上下文的构成

由此可以清晰地看到 agent 每次调用模型时上下文的完整构成:上半部分(System Prompt + 工具定义)在整个对话过程中保持不变,下半部分(对话历史,即轨迹)随交互不断增长。系统提示词和工具定义构成静态前缀,用户消息、模型回复和工具执行结果构成动态增长的消息历史。

这个“静态前缀 + 轨迹”的结构,是后续讨论 KV Cache 优化、上下文压缩等技术的基础——理解了它,就能理解为什么“前面不能动、后面可以压缩”。

工程实践

Zapvol 的这套“框架执行、模型决策”的循环就是 runAgentLoop()(见 Agent Engine):它一轮轮构造请求、把模型返回的 tool_calls 派发执行、再把 tool 结果追加进消息列表发起下一轮,直到模型不再请求工具。关键的“无状态”性质在这里兑现为崩溃可恢复——循环本身不持有需要持久化的状态,进程重启后重放消息记录(TaskRepository.getMessages())就能回到中断处。这也是为什么静态前缀绝不在中途改写、动态内容一律追加到末尾:既是 KV Cache 的要求,也是可重放的前提。

相关阅读

这页有帮助吗?