文章

vLLM 与 Responses API:会话历史、工具执行和 Agent 循环

用同一个天气查询例子对照 Chat Completions、无状态 Responses 和 previous_response_id,解释 vLLM 的历史存储、Redis 接入边界、服务端工具循环与远程执行环境。

vLLM 与 Responses API:会话历史、工具执行和 Agent 循环
  1. 同一次天气查询的三种接口形态
    1. Chat Completions:工具调用嵌在 assistant 消息中
    2. 无状态 Responses:调用与结果成为独立 Item
    3. 有状态 Responses:用 previous_response_id 引用历史
  2. 历史托管以后,多实例服务要保存什么
    1. 历史引用不等于模型获得持久记忆
    2. vLLM 原生存储位于 API 进程内
    3. 共享存储可以由网关承担
  3. 从返回工具调用到服务端继续推理
    1. Responses 为执行过程提供了条目结构
    2. vLLM 确实实现了内层工具循环
  4. 工具执行环境怎样接入 vLLM
    1. Demo 工具通过依赖包执行 Python 和搜索
    2. 远程 MCP 工具服务拥有自己的执行环境
  5. 客户端与服务端可以分担同一个循环
    1. 一个外层请求可以包含多轮内部推理
    2. 以待处理动作决定是否继续

把一个查天气的应用从 Chat Completions 改成 Responses API,客户端可以少传历史,还可能一次请求就拿到搜索、计算后的答案。这两种变化涉及不同的服务端能力:前者需要恢复上下文,后者还需要执行工具并继续调用模型。

沿着同一个工具调用例子,可以看清 Responses 改变了什么,以及 vLLM 的接口兼容、历史存储和工具运行时分别承担哪些工作。

同一次天气查询的三种接口形态

模型无法凭参数知道此刻的天气。应用向它声明 get_weather 函数,模型生成函数名与参数,应用查询天气,再把结果交给模型组织答案。声明函数只提供调用说明,并没有把函数实现上传给推理服务。 这是 OpenAI 函数调用文档描述的应用侧执行流程。

以下三组示例都使用同一任务:“北京现在天气怎么样?”工具结果固定为晴、23°C,仅用于演示协议。第一轮强制选择工具,第二轮只总结已有结果,不再提供工具;需要连续执行工具的应用应继续传入相应 tools。

请求体完整列出;响应保留本次全部消息和工具条目,省略 usage、创建时间等无关元数据。ID 均为示意值。示例对照的是 OpenAI 协议语义,具体 vLLM 模型与版本的支持边界在后文说明。

Chat Completions:工具调用嵌在 assistant 消息中

第一次请求,POST /v1/chat/completions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
  "model": "gpt-4.1-mini",
  "store": false,
  "messages": [
    {"role": "system", "content": "用中文简洁回答,天气信息以工具结果为准。"},
    {"role": "user", "content": "北京现在天气怎么样?"}
  ],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "get_weather",
        "description": "查询城市当前天气",
        "parameters": {
          "type": "object",
          "properties": {"city": {"type": "string"}},
          "required": ["city"],
          "additionalProperties": false
        },
        "strict": true
      }
    }
  ],
  "tool_choice": "required",
  "parallel_tool_calls": false
}

第一次响应,模型要求调用工具:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
  "id": "chatcmpl_101",
  "object": "chat.completion",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": null,
        "tool_calls": [
          {
            "id": "call_123",
            "type": "function",
            "function": {
              "name": "get_weather",
              "arguments": "{\"city\":\"北京\"}"
            }
          }
        ]
      },
      "finish_reason": "tool_calls"
    }
  ]
}

应用执行函数后,第二次请求必须带上原消息、assistant 的调用消息和工具结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
  "model": "gpt-4.1-mini",
  "store": false,
  "messages": [
    {"role": "system", "content": "用中文简洁回答,天气信息以工具结果为准。"},
    {"role": "user", "content": "北京现在天气怎么样?"},
    {
      "role": "assistant",
      "content": null,
      "tool_calls": [
        {
          "id": "call_123",
          "type": "function",
          "function": {
            "name": "get_weather",
            "arguments": "{\"city\":\"北京\"}"
          }
        }
      ]
    },
    {
      "role": "tool",
      "tool_call_id": "call_123",
      "content": "{\"city\":\"北京\",\"temperature\":23,\"condition\":\"晴\"}"
    }
  ]
}

第二次响应:

1
2
3
4
5
6
7
8
9
10
11
{
  "id": "chatcmpl_102",
  "object": "chat.completion",
  "choices": [
    {
      "index": 0,
      "message": {"role": "assistant", "content": "北京现在晴,气温 23°C。"},
      "finish_reason": "stop"
    }
  ]
}

无状态 Responses:调用与结果成为独立 Item

同一个函数,换成 POST /v1/responses。工具定义中的 name、parameters 等字段直接放在工具对象上,用户内容放入 input:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
  "model": "gpt-4.1-mini",
  "store": false,
  "instructions": "用中文简洁回答,天气信息以工具结果为准。",
  "input": [
    {"role": "user", "content": "北京现在天气怎么样?"}
  ],
  "tools": [
    {
      "type": "function",
      "name": "get_weather",
      "description": "查询城市当前天气",
      "parameters": {
        "type": "object",
        "properties": {"city": {"type": "string"}},
        "required": ["city"],
        "additionalProperties": false
      },
      "strict": true
    }
  ],
  "tool_choice": "required",
  "parallel_tool_calls": false
}

第一次响应中的工具调用是独立的 function_call Item:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
  "id": "resp_101",
  "object": "response",
  "status": "completed",
  "output": [
    {
      "id": "fc_101",
      "type": "function_call",
      "status": "completed",
      "call_id": "call_123",
      "name": "get_weather",
      "arguments": "{\"city\":\"北京\"}"
    }
  ]
}

应用执行函数后,第二次请求依然携带完整历史,只是换成 Item 序列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
  "model": "gpt-4.1-mini",
  "store": false,
  "instructions": "用中文简洁回答,天气信息以工具结果为准。",
  "input": [
    {"role": "user", "content": "北京现在天气怎么样?"},
    {
      "id": "fc_101",
      "type": "function_call",
      "status": "completed",
      "call_id": "call_123",
      "name": "get_weather",
      "arguments": "{\"city\":\"北京\"}"
    },
    {
      "type": "function_call_output",
      "call_id": "call_123",
      "output": "{\"city\":\"北京\",\"temperature\":23,\"condition\":\"晴\"}"
    }
  ]
}

第二次响应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
  "id": "resp_102",
  "object": "response",
  "status": "completed",
  "output": [
    {
      "id": "msg_102",
      "type": "message",
      "status": "completed",
      "role": "assistant",
      "content": [
        {"type": "output_text", "text": "北京现在晴,气温 23°C。", "annotations": []}
      ]
    }
  ]
}

有状态 Responses:用 previous_response_id 引用历史

第一次请求启用保存,其余任务和工具定义不变:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
  "model": "gpt-4.1-mini",
  "store": true,
  "instructions": "用中文简洁回答,天气信息以工具结果为准。",
  "input": [
    {"role": "user", "content": "北京现在天气怎么样?"}
  ],
  "tools": [
    {
      "type": "function",
      "name": "get_weather",
      "description": "查询城市当前天气",
      "parameters": {
        "type": "object",
        "properties": {"city": {"type": "string"}},
        "required": ["city"],
        "additionalProperties": false
      },
      "strict": true
    }
  ],
  "tool_choice": "required",
  "parallel_tool_calls": false
}

第一次响应的 Item 格式与无状态版本相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
  "id": "resp_201",
  "object": "response",
  "status": "completed",
  "output": [
    {
      "id": "fc_201",
      "type": "function_call",
      "status": "completed",
      "call_id": "call_123",
      "name": "get_weather",
      "arguments": "{\"city\":\"北京\"}"
    }
  ]
}

应用仍然需要执行 get_weather,然后发起第二次请求。区别是只提交历史引用和新增结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
  "model": "gpt-4.1-mini",
  "store": true,
  "previous_response_id": "resp_201",
  "instructions": "用中文简洁回答,天气信息以工具结果为准。",
  "input": [
    {
      "type": "function_call_output",
      "call_id": "call_123",
      "output": "{\"city\":\"北京\",\"temperature\":23,\"condition\":\"晴\"}"
    }
  ]
}

第二次响应仍然只包含本轮输出,不会把整段历史重复返回:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
  "id": "resp_202",
  "object": "response",
  "status": "completed",
  "output": [
    {
      "id": "msg_202",
      "type": "message",
      "status": "completed",
      "role": "assistant",
      "content": [
        {"type": "output_text", "text": "北京现在晴,气温 23°C。", "annotations": []}
      ]
    }
  ]
}

这里有三个不同的标识:resp_201 定位历史响应,fc_201 标识输出 Item,call_123 将函数调用与函数结果配对。第二轮的 store: true 让本轮也能被后续引用;上一轮 instructions 不会通过 previous_response_id 自动继承,因此示例重新发送。迁移说明

三种形式的区别可以归结为:

对照项Chat Completions无状态 Responses有状态 Responses
历史入口messagesinputprevious_response_id 加新增 input
函数调用assistant 消息内的 tool_calls独立 function_call Item同左
函数结果role: tool 消息function_call_output Item同左
本例由谁执行函数应用应用应用
本例需要几次模型 API 请求两次两次两次

仅引入 previous_response_id,没有把工具循环搬到服务端。 它改变的是历史交接方式。

历史托管以后,多实例服务要保存什么

客户端少传一段历史,服务端就必须能够通过 ID 找回那段历史。对于有负载均衡和滚动重启的推理集群,保存位置决定了这个引用是否可靠。

历史引用不等于模型获得持久记忆

在有效上下文、模型、模板和生成参数等价的前提下,手动传历史与服务端恢复历史可以产生等价的模型输入。模型不需要知道 HTTP 请求使用的是哪个接口;服务层负责把消息、工具结果等转换为模型接受的序列。此前的模型接口与上下文处理详细解释了这层转换。

但“模型收到完整上下文”需要限定为本次实际选入上下文窗口的内容。历史可能经过截断、压缩或筛选,不能理解成永远包含全部聊天记录。使用缓存时,也不一定从头重算已有前缀。previous_response_id 解决历史寻址,KV Cache 复用推理中的中间结果;二者职责不同。OpenAI 的历史链也不会使先前上下文免于输入 token 计费。会话状态文档 · Prompt Caching 文档

对于会返回 reasoning Item 的模型,手动续接时还应按接口要求保留这些条目,不能只提取可见文字。Responses 的输出可以包含消息、推理条目和工具调用,这是它比单纯历史引用更广的接口设计。推理模型文档

vLLM 原生存储位于 API 进程内

以下实现核查固定在 vLLM 提交 d9fcde70,避免把后续版本变化写成永久承诺。

OpenAIServingResponses 的 response_store、msg_store 和 event_store 是 Python 字典。保存功能由 VLLM_ENABLE_RESPONSES_API_STORE 控制,默认关闭;启用后,数据保存在处理请求的 API 进程内存 中。它既不是模型权重里的状态,也不是 GPU KV Cache。Responses 服务源码

由此可以直接推导出两个部署约束:

  • 后续请求需要到达持有历史的 API 进程;只保证同一台物理机器仍可能不够。
  • 进程重启后内存历史消失;粘性路由只能解决请求落点,不能解决持久化。

该实现也没有可直接配置的 Responses Redis 存储后端。通用插件机制不等于已经提供历史存储适配接口,不能靠添加一个 Redis 地址就让这些字典自动共享。

另外,该提交的非 Harmony 历史恢复路径在处理前次输出时,只将 ResponseOutputMessage 转回消息,没有同样恢复所有 function_call、reasoning 条目。因此,前面的标准协议示例不能被当成所有 vLLM 模型上的兼容性保证。历史转换源码

共享存储可以由网关承担

多实例部署可以让客户端继续传完整历史,也可以把 Responses 会话能力放到独立网关:各网关副本从共享存储读取历史,再将组装好的输入发给任意后端实例。

flowchart LR
    C["客户端<br/>previous_response_id + 新输入"] --> LB["负载均衡"]
    LB --> G1["网关副本 A"]
    LB --> G2["网关副本 B"]
    G1 <--> R[("共享会话存储<br/>Redis 等")]
    G2 <--> R
    G1 --> V["vLLM 实例池<br/>接收已组装上下文"]
    G2 --> V

这是一种架构选择。现成实现中,独立项目 vLLM Semantic Router 的 response_api 服务支持 Redis,包括 store_backend: redis 和连接配置;这项能力属于 Router,不能写成 vLLM 推理服务原生的 Redis 插件。Semantic Router 配置文档

历史共享解决后,服务端仍然不一定会执行任何工具。保存状态与驱动任务继续前进,是两项独立能力。

从返回工具调用到服务端继续推理

天气示例中,模型第一次返回函数调用后,任务停下来等待应用。要让一次 API 请求完成搜索、计算和回答,服务端还需要拿到工具实现,执行调用,并把结果送回模型。

Responses 为执行过程提供了条目结构

Chat Completions 已经支持工具调用。Responses 的变化包括将不同输出表示为独立 Item,并为托管工具及其执行过程提供对应结构。例如,自定义函数返回 function_call,托管搜索可以返回搜索调用条目,最终回答是 message;流式模式还可以持续发送相关事件。Responses 迁移说明 · 工具文档

这些结构让服务端有地方表达多步执行,但 Item 格式本身不包含执行能力。只有 function schema 时,服务端知道函数叫什么、参数怎样填写;只有接入执行器,才知道如何真正查询天气、运行代码或访问业务数据。

本文把“模型生成动作 → 执行工具 → 将结果加入上下文 → 再次生成”的过程称为工具循环,也就是讨论 Agent 时常说的 ReAct 式闭环;这里不要求模型输出可见的 Thought 文本。

vLLM 确实实现了内层工具循环

vLLM 的 _generate_with_builtin_tools 包含一个循环:调用引擎生成、将输出写入上下文、判断是否需要执行内建工具,执行后追加结果并重新构建下一轮输入。下列伪代码保留了控制关系:

1
2
3
4
5
6
7
8
9
10
11
12
# 简化自 vLLM 的服务端生成循环。
while True:
    output = await engine.generate(context)
    context.append_output(output)

    # 没有需要服务端执行的工具,就将本次响应交还调用方。
    if not context.need_builtin_tool_call():
        break

    result = await context.call_tool()
    context.append_tool_output(result)
    context.render_next_input()

实际代码调用的是 vLLM 内部的引擎接口,不是绕回 HTTP 再请求一次 Chat Completions。生成循环源码

是否执行工具并继续下一轮生成,由上下文实现和工具配置共同决定:

上下文实现用途服务端工具行为
SimpleContext普通输出收集与响应组织need_builtin_tool_call() 恒为 False
HarmonyContextGPT-OSS 的 Harmony 消息处理支持识别、分派已配置的内建工具
ParsableContext基于通用 parser 的处理路径实验性支持,需启用 VLLM_USE_EXPERIMENTAL_PARSER_CONTEXT

这些 Context 是上下文与分派逻辑,不是沙箱。在所核查版本中,内层执行判断仍主要围绕 browser、python、container 等约定命名空间;工具需要在服务端接入,并在请求中启用。ParsableContext 也不能视为全面等价支持,例如 demo browser 的对应执行方法仍抛出 NotImplementedError。上下文源码 · 工具包装器源码

因此,vLLM 既能只返回工具调用,也能为受支持工具运行服务端循环。具体行为由模型路径、部署配置与工具种类决定。

工具执行环境怎样接入 vLLM

循环控制已经知道何时执行工具,接下来需要一个可调用的执行环境。这个环境可以与 API 服务同机,也可以在独立机器上;是否隔离、是否保留文件和进程,则是另一组设计。

Demo 工具通过依赖包执行 Python 和搜索

启用 --tool-server demo 后,DemoToolServer 在依赖与环境验证通过时注册 Python、browser。vLLM 的包装器实际调用单独安装的 gpt-oss 包:Python 使用 PythonTool,browser 使用 SimpleBrowserTool 和 Exa 搜索后端,后者需要 EXA_API_KEY。这条路径直接调用工具包,不必先连接远程 MCP 服务。工具服务源码

默认 Python 后端的执行过程是:

  1. 通过 Docker SDK 连接 Docker daemon,准备 python:3.11 镜像。
  2. 每次调用创建并启动一个新容器。
  3. 将模型生成的代码写入 /tmp/script.py 并执行。
  4. 读取执行输出,转换成工具结果。
  5. 在 finally 中删除容器。

该行为来自固定版本 gpt-oss PythonTool。由此可知,默认变量和容器内文件不会跨工具调用保留:第一次只执行 x = 42,第二次只执行 print(x),新容器中没有这个变量。

会话历史连续,不代表执行环境连续。 模型可能从上下文知道先前的计算结果,但 Python 进程和文件系统仍然是新的。previous_response_id 不能替代沙箱会话 ID。

官方将 demo Python 工具定位为参考实现,容器默认没有额外配置网络隔离。需要长期会话、资源调度和更完整隔离时,应另外提供相应执行服务。vLLM 官方说明;容器与 MicroVM 的层次差异可参见本站的 CubeSandbox 原理。

远程 MCP 工具服务拥有自己的执行环境

配置 --tool-server tool-host:8000 时,当前 MCPToolServer 将地址组成 http://tool-host:8000/sse,通过 MCP 初始化连接、获取工具描述,再在执行阶段调用远程工具。这是该实现采用的传输路径,不代表所有 MCP 实现都必须使用 SSE。MCP 接入源码

flowchart TB
    subgraph V["vLLM 服务"]
        L["Responses 工具循环"] --> C["Context<br/>识别调用、累计结果"]
        C --> T["ToolServer 适配层"]
    end
    T --> D["DemoToolServer<br/>调用 gpt-oss 工具包"]
    D --> P["Docker daemon<br/>创建临时 Python 容器"]
    D --> E["Exa 搜索服务"]
    T --> M["MCPToolServer<br/>远程调用"]
    M --> R["独立工具服务"]
    R --> S["由工具服务管理的执行环境<br/>容器、VM、进程或业务接口"]

这里有三条独立边界:远程描述部署位置,沙箱描述隔离方式,MCP 描述通信协议。 一个远程 MCP 工具可以管理沙箱,也可以只是调用普通业务接口;vLLM 只需要能够调用受支持的执行器并取得结果。

当前 Harmony 与 Parsable 路径以本次 request_id 建立工具 session,远程连接携带 x-session-id;请求退出时会尝试对已使用的远程工具调用 cleanup_session。因此,跨请求保留沙箱状态需要工具服务明确实现生命周期,不能从历史引用自动推导出来。任意 MCP namespace 也不能仅凭被发现,就保证进入当前 vLLM 的内层执行分派。会话与分派源码

客户端与服务端可以分担同一个循环

服务端可能拥有搜索和 Python 环境,但只有业务应用能查询公司内部订单。这时不需要选择唯一的循环位置:服务端执行它负责的工具,遇到应用负责的函数时,把控制权交还客户端。

一个外层请求可以包含多轮内部推理

下面的例子中,客户端指调用模型 API 的业务应用,不一定是浏览器。服务端可以先搜索、计算,再返回 query_order;应用查询订单并提交结果后,服务端继续生成答案。

sequenceDiagram
    participant C as 客户端应用
    participant V as vLLM 服务
    participant M as 模型
    participant T as 工具环境
    C->>V: 第一次请求
    loop 服务端负责的工具调用
        V->>M: 推理
        M-->>V: 搜索或 Python 调用
        V->>T: 执行工具
        T-->>V: 工具结果
    end
    V->>M: 携带结果继续推理
    M-->>V: 调用 query_order
    V-->>C: function_call
    C->>C: 执行 query_order<br/>取得订单信息
    C->>V: 第二次请求<br/>function_call_output
    V->>M: 继续推理
    M-->>V: 最终答案
    V-->>C: 最终答案

这是根据两层分派机制得到的组合方式,实际可用工具仍需满足前述版本与配置约束。如果整个任务只使用服务端工具,第一次响应就可能给出最终答案:客户端循环运行一轮后正常结束,服务端内部则可能已经多次调用模型。

以待处理动作决定是否继续

客户端不必知道远端内部循环了几次,但必须知道哪些工具由自己负责,并正确消费响应条目。

响应内容客户端动作
待应用执行的 function_call执行对应函数,提交 function_call_output,继续循环
已执行的服务端工具记录展示或记录,不重复执行
最终答案,且没有待处理动作结束循环
失败或不完整状态按失败原因处理,不能当作成功完成

response.status == "completed" 只表示本次响应生成完成。 天气示例的第一次响应同样是 completed,但其中的函数仍等待应用执行。function_call 上的完成状态也不表示业务函数已经运行;不能仅看状态字段或是否存在文字就停止。

对于通用 Agent 客户端,保留外层工具循环很合理;对于执行能力和任务约定明确、工具完全托管的服务,一次请求后消费结果也可以。客户端应依据部署能力配置工具,不能仅凭 /v1/responses 路径猜测远端接管了哪些工作。

部署和迁移时,需要分别确认三项责任:历史由谁保存和恢复,工具由谁执行,下一轮推理由谁发起。 previous_response_id 只直接改变第一项;服务端托管工具增加第二项能力;服务端工具循环进一步承担第三项。Responses 为这些组合提供了接口表达,实际兑现到哪一层,要看服务实现与配置。

本文由作者按照 CC BY 4.0 进行授权