换模型不改 Agent?AgentScope Java 的 Formatter 层做了什么
很多人第一次看到 AgentScope Java 里有一层 Formatter,第一反应都会有点疑惑。
这名字听起来太轻了。
像是个把字段名改一改、顺手拼个 JSON 的薄适配层。
但你真去翻实现,会发现它比“格式化”这三个字重得多。
因为它扛的是一件很核心的事。
为什么你在业务层写的 Agent,不需要跟着 OpenAI、DashScope、Ollama、DeepSeek、GLM 的请求格式一起抖。
换句话说。
为什么很多时候,换模型时真正该变的,只是模型配置和适配层。
而不是 Agent 本身。
这篇我们就专门拆这层。
先看“只换一行”的外部体验
我们先不讲原理,先看框架想给业务方暴露出来的体验。
在 ModelRegistryExample 里,ReActAgent 可以直接这么配。
ReActAgent agent =
ReActAgent.builder()
.name("ModelStringDemo")
.sysPrompt("You are a concise assistant. Reply in one sentence.")
.model("qwen-plus")
.build();然后源码注释里直接写得很直白。
// .model("openai:gpt-4o")
// .model("dashscope:qwen-max")
// .model("anthropic:claude-opus-4-5")
// .model("gemini:gemini-2.0-flash")
// .model("ollama:llama3")它想表达的其实不是“字符串配置挺方便”。
而是这件事。
业务 Agent 不应该直接依赖模型厂商的请求格式。如果一个 Agent 从 qwen 切到 gpt-4o,就要跟着重写消息拼接、工具 schema、tool_choice、温度参数、system prompt 落点,那这个 Agent 本质上就还没有被框架托起来。
它只是被包了一层而已。
更直观一点。
同样是一个最小 ReActAgent,外层骨架其实可以基本不动。
ReActAgent agent =
ReActAgent.builder()
.name("Assistant")
.sysPrompt("You are a helpful AI assistant.")
.model("qwen-plus")
.toolkit(new Toolkit())
.build();你要切到别的 provider,理想情况应该是这种级别的变化。
.model("openai:gpt-4o")或者。
.model("ollama:llama3")这正是 Formatter 这一层存在的前提。
它要把“统一的 Agent 运行语义”,翻译成“各家模型 API 真正吃得下去的请求结构”。

把隐藏层翻出来看,真正变化的是哪一层
如果只看 .model("qwen-plus") 这种写法,你很容易低估 formatter 的存在感。
那我们把隐藏层翻出来看。
同样是把 ReActAgent 接到模型上,真正变化的主要是 model(...) 里面那一段。
先看现在文档里最常见的 DashScope 版本。
.model(
DashScopeChatModel.builder()
.apiKey(apiKey)
.modelName("qwen-plus")
.stream(true)
.enableThinking(true)
.formatter(new DashScopeChatFormatter())
.defaultOptions(
GenerateOptions.builder()
.thinkingBudget(1024)
.build())
.build())如果你切到 OpenAI,这一层会变成。
.model(
OpenAIChatModel.builder()
.apiKey(apiKey)
.modelName("gpt-4o")
.stream(true)
.formatter(new OpenAIChatFormatter())
.build())如果你切到本地 Ollama,又会变成。
.model(
OllamaChatModel.builder()
.modelName("llama3")
.baseUrl("http://localhost:11434")
.formatter(new OllamaChatFormatter())
.build())外层 Agent 骨架可以继续保持稳定。
真正变化的,是具体 Model 和它背后的 Formatter 组合。
这就是第 4 篇要拆开的重点。

Agent 为什么不该直接碰模型厂商格式
如果你把前 3 篇连起来看,这个边界其实已经越来越明显了。
第 1 篇我们说过,AgentScope Java 不是模型 SDK 包装层。
第 2 篇我们拆过,ReActAgent.call() 背后跑的是一整条 Runtime 链。
第 3 篇又看见了,Agent 运行里的最小消息单位不是字符串,而是 Msg。
到第 4 篇,这三件事会自然收敛成一个结论。
Agent 这一层,最好只关心下面这些统一语义。
Agent 关心什么 | 例子 |
|---|---|
输入消息 |
|
工具语义 |
|
生成控制 |
|
返回结果 |
|
它不该直接关心下面这些 provider 细节。
Provider 细节 | 为什么不该漏到 Agent 层 |
|---|---|
请求字段叫 | 这是厂商协议差异 |
| 这是厂商协议差异 |
| 这是厂商兼容性差异 |
多模态是不是要走另一套 endpoint | 这是厂商能力差异 |
thinking / reasoning 参数怎么落 | 这是厂商参数差异 |
只要这些差异漏进 Agent 层,业务代码就会开始带 provider 气味。
今天你切一个模型,改的不只是 builder。
明天你再接一个工具能力,连 prompt 和 tool 描述可能都得跟着改。
这就是框架边界没站稳。
Formatter 接口到底在负责什么
真正把这个边界钉住的,是 Formatter 接口本身。
它不是一个“把对象转 JSON” 的 util。
源码里定义得很明确,它负责四件事。
List<TReq> format(List<Msg> msgs);ChatResponse parseResponse(TResp response, Instant startTime);
void applyOptions(
TParams paramsBuilder, GenerateOptions options, GenerateOptions defaultOptions);
void applyTools(TParams paramsBuilder, List<ToolSchema> tools);
另外还有两个带默认实现的方法。
applyToolChoice(...)
applyTools(..., baseUrl, modelName)如果把这几个方法翻成人话,大概就是下面这个表。
方法 | 作用 |
|---|---|
| 把 AgentScope 的 |
| 把 provider 返回结果翻回统一 |
| 把 |
| 把统一 |
| 把工具调用策略映射到各家 tool choice 表达 |
注意这里最关键的一点。
Formatter 不是只处理“发出去的消息长什么样”。
它还负责。
- 请求前的参数映射工具能力映射返回后的响应解析
也就是说,它其实站在 Model 的边界上,承担的是一整次 provider 协议翻译。
不是一个字符串修饰器。

Model 是怎么把这层接进来的
再往下看 Model 这一层,你会发现它的抽象也刻意保持得很薄。
ChatModelBase 对外只暴露统一入口。
Flux<ChatResponse> stream(
List<Msg> messages, List<ToolSchema> tools, GenerateOptions options)也就是说,所有具体模型,吃进来的都是同样三样东西。
输入 | 含义 |
|---|---|
| 统一消息结构 |
| 统一工具 schema |
| 统一生成参数 |
ChatModelBase 自己不做 provider 逻辑。
它只是把 tracing 包上,然后交给各自的 doStream(...)。
真正和 provider 细节接触的,是具体模型类。
但即便到了具体模型,套路也很统一。
以 OpenAIChatModel 为例,主干大概就是下面这条链。
List<OpenAIMessage> openaiMessages = formatter.format(messages);OpenAIRequest.Builder requestBuilder =
OpenAIRequest.builder().model(modelName).messages(openaiMessages).stream(stream);
if (tools != null && !tools.isEmpty()) {
formatter.applyTools(request, tools);
}
formatter.applyOptions(request, effectiveOptions, null);
if (effectiveOptions.getToolChoice() != null) {
formatter.applyToolChoice(request, effectiveOptions.getToolChoice());
}
这段非常值得看。
因为它说明 Model 本身没有把 OpenAI 规则硬编码进 Agent 运行里。
它只是把三件统一输入交给了 formatter。
然后让 formatter 去决定:
Msg 怎么变成 OpenAI message,ToolSchema 怎么变成 OpenAI tools,GenerateOptions 怎么变成 OpenAI request 参数。
DashScope 也是同一个思路。
它先拿统一的 List<Msg>,再决定走文本还是多模态格式。
if (useMultimodal) {
dashScopeMessages = chatFormatter.formatMultiModal(messages);
} else {
dashScopeMessages = formatter.format(messages);
}然后再让 formatter 去 build request。
request =
chatFormatter.buildRequest(
modelName,
dashScopeMessages,
stream,
options,
defaultOptions,
tools,
toolChoice);Ollama 也是一样。
List<OllamaMessage> formattedMessages = formatter.format(messages);request =
chatFormatter.buildRequest(
modelName,
formattedMessages,
stream,
options,
defaultOptions,
tools,
toolChoice);
你看,到这一步就很清楚了。
真正保持稳定的,不是 provider request。
而是 Model.stream(messages, tools, options) 这个统一边界。
而这个边界之所以能成立,靠的就是 formatter 在里面兜底。
这层不是简单 DTO 映射,它还会吸收 provider 脾气
如果 Formatter 只是机械字段映射,那它还没这么重要。
它真正有价值的地方在于。
它还会顺手吸收很多 provider 的怪脾气。
最典型的例子,在 AbstractBaseFormatter 里就能看见。
比如 extractTextContent(Msg msg) 这段公共逻辑,会显式跳过 ThinkingBlock。
源码注释写得很清楚。
// ThinkingBlock is stored in memory but skipped when formatting messages这其实是在帮 Runtime 做边界决策。
不是所有保存在 Memory 里的内容,都应该原样再喂回模型 API。
再比如工具结果。
AbstractBaseFormatter 会把 ToolResultBlock 的输出转成 provider 能接收的文本引用形式。
如果结果里带图片、音频、视频,它还会做额外转换。
这说明 formatter 处理的不是单纯字段名。
它处理的是。
哪些运行语义该保留
哪些内容该降级
哪些块该跳过
哪些块要换一种 provider 能接受的表达这已经不是“格式美化”了。
这其实是在做协议层语义翻译。
同一组消息,到了不同 provider,请求长得真的不一样
我们可以把第 4 篇最核心的认知压成一句特别朴素的话。
同一组 Msg,到了不同模型那里,请求结构真的不是一回事。
从真实代码看,至少有这几个层面的差异。

维度 | OpenAI | DashScope | Ollama |
|---|---|---|---|
请求主体 |
|
|
|
消息落点 |
|
|
|
参数落点 | 直接挂 request 字段 | 主要进 | 主要进 |
多模态分支 | 同一套 formatter 内处理 | 可能切 | 图片会转 base64 |
工具调用结构 |
|
|
|
只看这张表,你大概会觉得这也还好。
但再往下翻一点,差异就更具体了。
OpenAIChatFormatter.applyOptions(...) 里会直接把这些参数映射到 request。
request.setTemperature(temperature);
request.setTopP(topP);
request.setMaxTokens(maxTokens);
request.setParallelToolCalls(parallelToolCalls);同时 applyTools(...) 还会把统一 ToolSchema 变成 OpenAITool。
applyToolChoice(...) 又会把工具策略映射成 "auto"、"none"、"required" 或具名函数对象。
而 DashScope 这边不是直接一把 set。
它会先准备 DashScopeParameters,再通过 helper 把 options、tools、tool choice 塞进去。
DashScopeParameters params = request.getParameters();
toolsHelper.applyOptions(params, options, defaultOptions);
params.setTools(toolsHelper.convertTools(tools));
toolsHelper.applyToolChoice(params, toolChoice);Ollama 更不一样。
它在 formatter 里会先把消息拆开处理。
ToolResultBlock 会被单独转成 role="tool" 的消息。
如果启用了 promoteToolResultImages,甚至还会把工具结果里的图片再提升成额外消息。
这已经不是“同一个字段改个名字”能概括的差异了。
它是整个 provider 协议表达能力不同。
所以 Agent 层如果直接碰这些东西,代码一定会越来越脏。
真正难的地方,是厂商兼容性不是一条直线
还有一个特别容易被低估的点。
有时候即便你看起来都走“OpenAI 兼容接口”,也不代表格式真的一样。
DeepSeekFormatter 就是很好的例子。
它直接继承 OpenAIChatFormatter,但会额外打补丁。
源码注释里写了几条 DeepSeek 的特殊要求。
DeepSeek 特殊规则 | 对应处理 |
|---|---|
message 里不能带 | formatter 里修正 |
system message 要转成 user | formatter 里修正 |
不支持 tool 的 |
|
thinking 模式要保留特定 | formatter 里修正 |
也就是说。
哪怕上层都叫“OpenAI-style request”,底层 provider 之间依然会有不兼容。
这时候最合理的做法,不是把这些兼容判断泄漏给 Agent。
而是让 formatter 在 provider 适配层里吞掉。
所以 Formatter 这层真正屏蔽的,不只是大类差异。
它还屏蔽了同类协议里的细碎脾气。

GenerateOptions 为什么也必须走 Formatter
很多人一开始只会把 formatter 想成“消息翻译器”。
但如果你看 Formatter 接口,会发现 GenerateOptions 也明确在它的职责里。
这是对的。
因为温度、top_p、max_tokens、thinking_budget、tool_choice 这些东西,看着都是“模型参数”,但它们落到不同 provider 请求里时,位置和表达方式并不统一。
而且在进入 formatter 之前,框架还会先做一次统一合并。
GenerateOptions.mergeOptions(primary, fallback) 的语义很清楚。
调用时传入的 options 优先
builder 里的 defaultOptions 兜底
Map 类配置做覆盖合并这意味着业务侧可以只表达“我这次想要什么生成行为”。
至于这些行为应该落成哪家 provider 的哪个字段,是 formatter 的事。
如果把这件事放回 Agent 层,你很快就会写出这种代码。
如果是 OpenAI,就设 temperature
如果是 DashScope,就改 parameters
如果是 Ollama,就转成另一套 options这基本就宣告框架边界失守了。
所以 GenerateOptions 之所以要和 Formatter 绑在一起,不是设计师多想了一步。
而是 provider 参数天然就不是统一平面的。

业务真正该稳定的,是 Agent 这一层
如果把第 4 篇收回到工程实践,我会特别建议把边界记成这个表。
层 | 更适合关心什么 |
|---|---|
Agent | 任务目标、消息流、工具协作、记忆与状态 |
Model | 一次统一模型调用边界 |
Formatter | provider 请求/响应/工具/参数协议翻译 |
Provider SDK / HTTP Client | 真正的网络调用和厂商接口细节 |
所以业务里更健康的写法通常是。
Agent 代码尽量只表达“我要做什么”
Model + Formatter 负责表达“这件事怎么跟具体模型说”这也是为什么你回头看 BasicChatExample、ModelRegistryExample,会觉得它们都在传达同一个方向。
框架希望你写的是统一 Agent 逻辑。
而不是每接一家模型,就复制一份新的 Agent。
把这一层压成一句话
如果你想把第 4 篇的核心只记一句,我觉得最贴源码的一句是。
Formatter 不是把消息“格式化一下”,而是把 AgentScope 的统一运行语义,翻译成不同 provider 各自能理解、能执行、能返回的完整协议。
你也可以记成下面这个链路。
阶段 | 统一输入/输出 | 适配层动作 |
|---|---|---|
Agent 发起推理 |
| 不碰 provider 细节 |
| 统一模型调用边界 | 把输入交给具体 model |
|
| 翻译消息结构 |
| 统一选项和工具语义 | 翻译请求参数与工具协议 |
Provider API 返回 | provider response | formatter 解析 |
|
| 回到框架统一语义 |
所以从第 4 篇开始,你对 AgentScope Java 应该再多一层感觉。
它不是“支持很多模型”这么简单。
它是在试图把Agent 运行语义和模型厂商协议
拆成两层东西。
这样 Agent 才有机会长成可迁移的业务单元,而不是跟着 provider 格式一起长歪。
下一篇我们就暂时离开适配层,回到 ReAct 本体。
不先看现成实现,直接用 Java 手写一个极简 Agent 循环。
继续阅读
基于全文检索与主题相似度
工具描述写不好,Agent 就会乱调用:一次 Tool Schema 实验
事情是这样的。 上一篇我们把 AgentScope Java 的 Tool 拆清楚了。 Tool 不是一个普通 Java 方法。 它至少有两面。 这件事一旦理解了,后面一个问题就会变得很刺眼。 既然模型看到的不是 Java 方法体,而是一份 ToolSchema。 那这份 schema 写得好不好,...
AgentScope Java 的 Tool 到底是什么?它不是简单的 Java 方法
事情是这样的。 前面几篇我们已经把 AgentScope Java 的运行心智拆到一个比较清楚的位置了。 Agent 不是一次模型调用。 ReActAgent.call() 背后是一条执行链。 Msg 不是普通字符串。 Formatter 负责把统一消息结构翻译成不同模型厂商的请求。 第 5 篇又把...
ReAct 不神秘:我用 Java 手写了一个极简 Agent 循环
事情是这样的。 前面几篇我们一直在拆 AgentScope Java 的运行结构。 第 1 篇讲的是,Agent 不是一次模型 API 调用。 第 2 篇讲的是,agent.call(...) 背后不是一跳,而是一整条执行链。 第 3 篇讲的是,Msg 不是 String,因为 Agent 运行里要...