流式 Token 与工具调用:NVIDIA Dynamo 中的多轮 Agent 客户端支持
流式 Token 与工具调用:NVIDIA Dynamo 中的多轮 Agent 客户端支持
一次完整的 Agent 交互需要保留结构化的会话信息:每个 assistant 轮次会把"推理(reasoning)"与一个或多个工具调用(tool call)交错在一起,而下一个 user 轮次则要把对应的工具结果回填到模型上下文中。推理片段是否需要在下一轮被重放,并不是一刀切的——它既与模型有关,也与轮次类型有关:有些推理需要保留,有些则必须丢弃。
这种更具表达力的交互模型,需要推理引擎来支撑,并产出正确分段的 API 响应。工具调用解析(tool-call parsing)与推理解析(reasoning parsing)必须在客户端 harness 拿到响应之前完成。同时,像代码生成这类高价值的 Agent 工作流,还高度依赖 harness 的响应体验:推理片段、工具调用事件以及请求元数据,必须随着一轮生成的展开边生成边流回去,而不是等到最终的文本回复全部完成后才一次性下发。
本文总结的是我们用真实的 Agent 客户端去对接 NVIDIA Dynamo 时积累下来的经验:我们如何加固解析器与 API 覆盖度,如何改进流式行为,以及如何把这些解析层抽离为可独立复用的 crate。
这些改动建立在我们第一篇博客中讨论的性能基础之上。那篇博客聚焦在 Agent 推理底层的服务架构——前端、路由器以及 KV cache 管理。本篇延续这条主线,但聚焦在正确性、用户体验对齐与性能上。
Agent 客户端(harness)当下仍在快速演进。Claude Code、Codex 和 OpenClaw 通过不同的 API 表面暴露了许多相同的压力点,因此下文的示例都围绕"自定义服务栈想要追平这些客户端所必须实现的核心行为"展开。

图 1:标准推理服务器与 Dynamo 在两轮 Agent 交互中的对比。Dynamo 通过稳定前缀缓存与解析后立即派发工具调用,显著降低了重复构建 prompt 与解析的开销。
Dynamo 面向客户端的关键配置
我们的实验使用了刚刚发布的 nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4 模型,但同样的问题在其它模型、推理解析器和工具调用解析器上都会出现。
要复现本文的结果,请在前端启用 Anthropic 兼容 API,并打开下列保留 prompt、推理与工具状态的开关:
--enable-anthropic-api:向客户端暴露 Anthropic Messages API。许多客户端虽然可以回退到默认的 Messages API,但体验会明显退化。--strip-anthropic-preamble:移除会破坏 KV cache 复用的 Anthropic 计费头。--enable-streaming-tool-dispatch:一旦完整的工具调用被解码出来就立即派发执行,而不是等到这一轮结束。
完整命令如下:
python -m dynamo.frontend \
--http-port 8000 \
--enable-anthropic-api \
--strip-anthropic-preamble \
--enable-streaming-tool-dispatch
worker 端在这套部署里最关键的两个参数是:
--dyn-tool-call-parser <parser>与--dyn-reasoning-parser <parser>:按模型期望的格式重建工具调用与推理块。这两个解析器同时也控制上一轮的推理在下一轮里是被保留、被改写还是被丢弃。
Prompt 稳定性是缓存复用的关键
Claude Code 在每次请求时会下发数千个 token 的 prompt 脚手架,这部分本应在跨用户、跨会话之间保持完全一致。但是,每个 prompt 的开头都会带上一段 会话相关 的计费 header——如果它没有被剥离掉,自定义后端就会出现 cache miss:
x-anthropic-billing-header: cc_version=0.2.93; cch=abc123def456==;
You are Claude Code, an interactive CLI tool...
这种 header 会"污染"KV cache,让缓存即使在同一个用户的不同会话之间也无法被复用。位置 0 上有一行随会话变化的内容,意味着每个新会话开头的 token 前缀都不一样,于是后面那些本应稳定的指令与工具定义,永远没法干净地对齐到一个可复用的前缀上。
为了恢复 KV cache 复用,Dynamo 新增了 --strip-anthropic-preamble 选项。改动机制上很小,运行时影响却很大:在 tokenize 之前剥掉这段不稳定的计费 header,让稳定的 prompt 从第 0 个 token 开始。
实测结果非常显著。在一台 NVIDIA B200 上跑 Dynamo,prompt 长度 52K token 时,稳定前缀对应的 TTFT 为 168 ms;在前缀里保留随会话变化的 header 会把这个时间推高到 912 ms;而在 tokenize 之前剥掉该 header,则恢复到 169 ms。也就是说,这段不稳定 header 在该工作负载下平均要消耗 744 ms/请求,足以把一段可复用的系统 prompt 重新变回一次冷预填。换算下来,新用户访问同一部署、或老用户开启新会话时,TTFT 大约能降低 5 倍。

图 2:基准测试显示,剥离会话粒度的 header 后,前缀缓存被恢复,TTFT 降低约 5 倍。
推理与工具解析的细微差别
推理片段在下一轮被"重放"时,没有一种放之四海皆准的正确做法。有些模型在普通 assistant 轮次会刻意丢弃此前的推理;但带有工具调用的 Agent 轮次则完全不同——那些推理跨度往往必须继续与它们所解释的工具调用保持绑定。真正的契约是逐模型、逐轮次决定的。
Anthropic 在 4 月 23 日 Claude Code 复盘 中给出了一个具体的生产案例:当缓存的 prompt 过期后,恢复会话时可以清掉过往轮次的 thinking,以减小预填压力。
当代的推理模型大致会产出两类 assistant 轮次:
- 推理后直接给出对用户的回答
- 推理后给出一个或多个工具调用
Agent 类模型尤其擅长在一次响应里多次交错地输出推理与工具调用,形如:
<think>reasoning_0</think> tool_call_0 <think>reasoning_1</think> tool_call_1
到了下一轮,每一段推理必须继续紧贴它所解释的那个工具调用。Dynamo 现已完整支持这种交错格式。在此之前,同一轮的内容可能被重建成下面这种"压扁"的样子:
<think>reasoning_0 reasoning_1</think> tool_call_0 tool_call_1
如果 assistant 轮次被重建成"一段通用推理 + 一坨工具调用"的形式,模型表面上拿到的 token 数没变,但顺序与分隔符这两条让 token 真正有意义的信息丢了。这种分组顺序源自早期模型——它们一轮只输出一段推理和一次工具调用 pass。
除了顺序错乱,我们还发现推理在进入下一轮之前经常被"清得太狠"。对一部分模型来说,在没有工具调用的轮次里丢掉先前的思考确实是一种既定行为,并且是其微调(fine-tuning)的一部分(DeepSeek-R1 是最典型的例子)。但同样的策略放到带有交错推理的 Agent 轮次里就是错的——那里的推理是用来解释整个工具序列的。这个 bug 最难发现的地方在于:用户能看到推理在当下响应里被正确解码出来,但它在进入下一轮之前被悄无声息地畸变或丢弃。
我们在一个 Dynamo + TRT-LLM 部署上验证了这一点:Nemotron-3-Super-120B-A12B-NVFP4,4× B200,TP=4,开启 --enable-anthropic-api、--strip-anthropic-preamble、--enable-streaming-tool-dispatch,推理解析器使用 nemotron_deci,工具调用解析器使用 qwen3_coder。
推理与工具调用的组合
当一个模型在调用工具前先做推理时,它产生的响应里会先有 <think> 内容,再跟着 <tool_call> XML。在 Nemotron 这个例子里,两个不同的解析器——nemotron_deci 负责推理、qwen3_coder 负责工具调用——必须在不互相干扰的前提下,把同一个流切成正确的 Anthropic Messages API 内容块。
我们通过 Anthropic Messages API 发送了同一个 prompt 5 次:一个要求模型"分步思考"的系统 prompt、两个工具定义(calculator 与 weather),以及用户消息"Think carefully about what 15 * 23 equals, then use the calculator to verify."。其中一次代表性的响应结构如下:
{
"content": [
{
"type": "thinking",
"thinking": "I need to calculate 15 * 23. Let me think: 15 * 20 = 300, and 15 * 3 = 45, so 300 + 45 = 345. I'll use the calculator to verify.\n"
},
{
"type": "tool_use",
"id": "call-a3364797-3160-4e84-b567-5c495694d502",
"name": "calculator",
"input": { "expression": "15 * 23" }
}
],
"stop_reason": "tool_use",
"usage": { "input_tokens": 403, "output_tokens": 95 }
}
两个解析器同时流式工作
走流式路径时,两个解析器的相互作用更明显。一次流式请求会产出一连串 SSE 事件,事件类型的时序刚好揭示了两个解析器如何切分 token 流:
1ms message_start
82ms content_block_start type=thinking
82ms content_block_delta (thinking tokens stream here, ~7ms apart)
... (~70 thinking deltas over ~520ms)
602ms content_block_stop
602ms content_block_start type=text
602ms content_block_delta
800ms content_block_stop
800ms content_block_start type=tool_use
800ms content_block_delta
800ms content_block_stop
814ms message_delta stop_reason=tool_use
814ms message_stop
thinking 块在 82 ms 到 602 ms 之间逐 token 流式输出;随后是一个非常短的 text 块(对应原始 token 流中 thinking 与 tool_call 之间的空白);接着是 800 ms 时作为一个完整结构单元到达的 tool_use 块;最后 message_stop 在 814 ms 关闭整个响应。
在 PR #7358 之前,这条回路并不会产生正确的 Anthropic 事件序列。修复分三步:
- 推理解析的明确归属:以前推理解析会在多个层级互相竞争。后端解析器会把模型输出拆成
reasoning_content与普通content,与此同时 Anthropic 流式转换器在把同一份流映射到 Anthropic content block 时,又会自己去推断<think>的边界。PR #7358 让归属明确化:如果后端路径已经产出结构化的推理增量,Anthropic 转换器就信任它,只负责把它映射到响应格式中。 - 优先使用模板原生的推理支持:Dynamo 现在会检查当前 chat template 是否懂得读
reasoning_content。像 Nemotron 与 Qwen3 的模板能直接读这个字段,Dynamo 就什么也不改,把"保留多少推理"的决定权交给模板。如果模板只懂content,Dynamo 才退回到旧表示——通过在content中插入<think>块来保留推理,或者按模型/解析器策略把推理直接丢掉。Rust 预处理路径(ModelInput::Tokens)和 Python worker 路径(ModelInput::Text)共用同一条条件规则。 - 尊重请求粒度的 thinking 开关:许多模板默认
truncate_history_thinking=true,以节省上下文。对普通对话来说这是合理的,但对 Agent 工作流来说,这会把先前工具调用背后的推理一并抹掉。Dynamo 现在只在"推理确实在起作用"的请求上才改变这个行为:当配置了推理解析器且客户端没有显式关闭 thinking 时,Anthropic 路径会设置enable_thinking=true且truncate_history_thinking=false。这样既能保住 Agent 所需的下一轮上下文,又不会影响那些本就不应启用 thinking 的模型与请求。
在我们的 B200 实验里,52K token 系统 prompt 加上约 500 token 推理内容的 assistant 轮次,下一轮前缀如果保持不变,TTFT 是 167 ms;如果推理内容在进入下一轮前被改写,TTFT 则会升到 322 ms——增加了约 1.9 倍,约 155 ms/请求。
核心结论是:客户端、解析器与模板路径必须就该模型期望的推理行为达成一致。普通轮次丢掉 thinking 对一个模型是正确的,对另一个模型可能就是错的;带工具调用的轮次保留交错推理可能至关重要,即使普通轮次允许被裁掉。在实践中,不要假设第 N 轮产出的 token 一定会作为第 N+1 轮的前缀原封不动地到达——这取决于你所服务模型的推理解析器、工具解析器以及 chat template。
流式工具调用
流式 token 让用户体验更具响应感、更动态。挑战在于:如何在保留这种响应感的同时,仍然把工具调用作为一个完整一致的块对外发出。早期 Dynamo 路径上,推理 token 是正常流式回传的,但工具调用会被一直缓冲到一轮结束才一次性发给客户端。这既降低了响应感,也延迟了工具执行——尽管模型其实早就决定要调用什么了。
| 状态 | 客户端看到的内容 | 何时能感知到工具就绪 |
|---|---|---|
| 缓冲(Buffered) | 工具调用片段被压下不发 | 只在 finish_reason: "tool_calls" 时 |
| 内联流式(Inline streaming) | 常规的工具调用增量 | 模型一吐出就能看到 |
| 派发(Dispatch) | 类型化的 event: tool_call_dispatch 旁路通道 |
在结构性完成的同一时刻,但已被解析好 |
最关键的过渡是从第一行到后两行——这正是客户端不再"傻等流结束才知道该动手"的分水岭。没有派发机制时,客户端只能看到普通 token 流,必须自己累积增量、等到结构足够完整时再推断工具调用是否结束。开启派发后,Dynamo 可以直接发出一个类型化的 SSE 旁路事件:
event: tool_call_dispatch
data: {"choice_index":0,"tool_call":{"index":0,"id":"call-...","type":"function","function":{"name":"calculator","arguments":"{\"expression\":\"42 * 17\"}"}}}
这条事件一次性告诉客户端:工具调用已经可以执行了。客户端不再需要自己拼增量、不再需要猜参数是否齐全、也不再需要在 harness 内部内嵌一份解析器。这让 Dynamo 与自定义客户端的对接更加顺畅。

图 3:时序对比显示 Dynamo 在工具调用解析完成后立即派发,而不是等到响应流结束。
面向 Claude Code 与 OpenClaw 的 Anthropic API 兼容性
Claude Code 与 OpenClaw 都直接调用 Anthropic Messages API,而不是在端点后面只走纯文本生成。要复刻原生 harness 的体验,依赖一组在临时测试里很容易漏掉的小行为:
GET /v1/models与GET /v1/models/{model_id}上都要返回模型元数据- 正确处理带斜杠的 model ID
- 在
message_start中返回有效的input_tokens - 接受
cache_control字段
前端可达且行为兼容之后,两种 harness 都可以指向 Dynamo 的 Anthropic 兼容端点:
ANTHROPIC_API_KEY=local-dev-token \
ANTHROPIC_BASE_URL=http://localhost:8000 \
ANTHROPIC_CUSTOM_MODEL_OPTION=nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4 \
ANTHROPIC_CUSTOM_MODEL_OPTION_NAME="Dynamo NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4" \
claude --model nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4
ANTHROPIC_API_KEY=local-dev-token \
ANTHROPIC_BASE_URL=http://localhost:8000 \
npx openclaw agent --local -m "Say ok" --json
这一系列修复让自定义部署的行为更贴近原生后端。一个具体例子比一长串清单更能说明问题。启动期间,harness 会直接询问所选模型的详情,而 Dynamo 此前还没暴露这个端点:
GET /v1/models/nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4
HTTP/1.1 404 Not Found
另一个例子:message_start 里的 input_tokens 报 0,即使最终响应里出现了真实计数。这会让 harness 每开一轮就把 token 计数暂时归零。PR #7234 修复了 Anthropic 路径,使其在流开始之前就填好 input_tokens。这些计数对长会话还是控制面数据——harness 用它们来判断什么时候在下一个请求超出模型窗口前先压缩对话。更广义的 tokenizer 服务工作在 PR #7699 中单独落地,新增了 /v1/tokenize 与 /v1/detokenize 端点,可在请求进入引擎之前给出精确的 token 计数。
Responses API 与 Codex 兼容性
Codex 这一侧的等价问题落在 v1/responses 接口上。通过合规性测试并不等于在用户体验上达到一致。我们发现:一个 Responses API 请求在 Dynamo 内部走一圈之后,原本让它"成其为 Responses 请求而非 chat completions 请求"的字段会被丢掉。要保住这些字段,需要对 Dynamo 的 ResponseParams 路径做架构性改动,并配合上游的类型对齐工作(PR #6089)。
Codex 应通过 OpenAI 兼容的 Responses API 指向 Dynamo,并开启请求压缩:
OPENAI_API_KEY=local-dev-token \
codex exec \
-c 'openai_base_url="http://localhost:8000/v1"' \
-c 'features.enable_request_compression=true' \
-m nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4 \
"Say ok"
Codex 的模型元数据塑造请求形态
Codex 的对齐工作其实在第一次 POST /v1/responses 之前就开始了。CLI 会先把配置的 model 字符串解析为一条本地 model catalog 记录,由此得到的 ModelInfo 决定了围绕该模型构建的整条客户端状态:base instructions、历史格式化、工具注册表、推理参数、verbosity 控制、图像支持、上下文计算、工具输出截断策略、parallel_tool_calls,乃至最终的 Responses payload。
两个端点完全可以服务同一个底层模型,却因为 Codex 给它们附着了不同的 catalog 元数据,导致 Agent 行为完全不同。请求可以通过 schema 校验,但围绕请求的 harness 早已变了样。
工具输出截断就是一个很有代表性的例子。Codex 不会把无限制的命令输出原样塞进下一轮模型上下文:所选模型的 catalog 策略会先把 shell 与工具的观察结果按策略截断,再重新进入 context。在我们测试的 catalog 快照中,gpt-5.5 使用:
{ "mode": "tokens", "limit": 10000 }
而自定义端点上的 openai/openai/gpt-5.5 走的是 fallback 元数据:
{ "mode": "bytes", "limit": 10000 }
两者并不等价。对充满 ASCII 的代码输出来说,10000 字节限制会比 10000 token 限制提前得多地切断结构化日志、堆栈跟踪、JSON 或测试输出。对一个编程 Agent 来说,这就改变了它在测试失败、搜索命令或编译错误后能回看到多少上下文,模型可能需要额外的工具调用才能找回本来 catalog profile 会替它保住的那些信息。
推理设置也是 catalog 决定的。当所选模型元数据声明支持推理摘要时,Codex 才会发送 Responses 的 reasoning 对象;在这条路径上,Codex 还会请求 reasoning.encrypted_content,以便推理状态能跨轮重放。Fallback 元数据会把这条路径完全拿掉。
Prompt 也会变。Codex 在从 fallback/default profile 切到 gpt-5.5 catalog profile 时,系统 prompt 也会随之切换。Fallback prompt 围绕通用 Codex 操作组织(# How you work、# AGENTS.md spec、# Tool Guidelines),强调 AGENTS.md 的优先级、规划、验证与 shell 搜索习惯;gpt-5.5 的 prompt 则是另一份指令文档(# Personality、# General、# Working with the user),把 Agent 描绘成一个务实的软件工程师,并对代码阅读、本地模式复用、范围受控的修改、脏工作区处理、apply_patch、协作更新与最终答案格式提出了更强的指导。换句话说,catalog 别名不仅影响截断与推理这类请求字段,还会影响 Agent 的基础行为策略。
我们在 SWE-Bench Verified 的一个 50 任务子集中直接观察到了这种差异。该实验中两条路径都最终落到 OpenAI 托管的 GPT-5.5——差异在于端点本身,以及 Codex 给它附着的 model catalog 记录。当自定义端点用的是 openai/openai/gpt-5.5 这个 model ID、但没有关联到 gpt-5.5 catalog profile 时,Codex 会退回到通用 fallback 行为。在其中一次运行里,fallback profile 发起的工具调用数大约只有一半:
| Catalog profile | 总工具调用数 | 每任务平均 |
|---|---|---|
gpt-5.5 profile |
2,087 | 41.7 |
| Fallback profile | 1,048 | 21.0 |
| 差值 | -1,039 | -20.8 |
逐任务的配对比较结论方向一致:gpt-5.5 profile 在 50/50 任务上使用了更多工具,fallback profile 在 0/50 任务上更多。置换检验给出 p < 0.001。
我们随后增加了一条 model catalog 别名,让 openai/openai/gpt-5.5 继承到原本期望的 gpt-5.5 profile。同样的 50 任务设置下,两条路径就贴近多了:
| Catalog profile | 总工具调用数 | 每任务平均 |
|---|---|---|
gpt-5.5 profile |
2,081 | 41.6 |
| 别名映射后的自定义 profile | 2,205 | 44.1 |
| 差值 | +124 | +2.5 |
此时剩余的差异在统计上不再显著:置换检验约为 p = 0.22,配对方向也是混合的(20/50 任务偏向原生 profile、28/50 偏向别名 profile、2/50 持平)。
对 Dynamo 而言,结论是:Codex 兼容性必须在 catalog 与请求塑形这一层评估,而不仅是 HTTP schema 层。如果 Codex 无法把一个 model ID 解析到预期的 profile,fallback 默认值就可能在请求到达 Dynamo 之前,把截断策略、搜索工具可用性、verbosity 控制、推理摘要支持以及 parallel tool call 支持统统改掉。
下一步
Dynamo 现已支持 nvext.agent_hints,包含 latency_sensitivity、priority、osl 以及 speculative_prefill 等字段。这些字段让客户端可以在 prompt 之外,把更多关于"这一轮的性质"的信息传给后端:一个正在等待用户回复的会话,与一个正在跑长背景工具序列的会话并不相同,而 API 现在能承载这种差异。
在 v1.1.0 主线上,Dynamo 还把更多 Agent 栈组件以可复用的独立 crate 的形式开放出来:协议、解析器、tokenizer 层分别成为版本化的 dynamo-protocols、dynamo-parsers 与 dynamo-tokenizers,让团队能在不复制 Dynamo 内部实现的前提下,构建或定制自己面向客户端的服务路径。
这也是通往 AutoResearch 这类长时运行系统的桥梁。第一篇博客 解释了 Agent 工作负载为什么会给服务栈带来如此大的压力;本文聚焦在正确运行这些工作负载所必需的"面向客户端的契约",并为后续基于 Dynamo 端点的高效长时 Agent 打下基础。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)