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_time、get_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\", ...}" }
]
这里有三个关键细节:
- 第二次请求包含了第一次的全部对话历史——这就是“每次调用都是无状态的”:模型不会“记住”上一次的对话,agent 框架必须每次都把完整历史送回去。
- 第一次的 assistant 消息被原样放回消息列表——这让模型能“看到”自己之前做了什么决策。
- 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 的要求,也是可重放的前提。
相关阅读
- KV Cache 友好的上下文设计——静态前缀不变性如何被缓存利用,以及一行动态内容如何毁掉它。
- Agent Engine——
runAgentLoop()对这个核心循环的具体实现。