换模型不改 Agent?AgentScope Java 的 Formatter 层做了什么

AgentScope Java 原理与企业级智能体实战37 次阅读27 分钟

很多人第一次看到 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 真正吃得下去的请求结构”。

01-one-line-switch.png

把隐藏层翻出来看,真正变化的是哪一层

如果只看 .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 篇要拆开的重点。

02-model-formatter-pair.png

Agent 为什么不该直接碰模型厂商格式

如果你把前 3 篇连起来看,这个边界其实已经越来越明显了。

第 1 篇我们说过,AgentScope Java 不是模型 SDK 包装层。

第 2 篇我们拆过,ReActAgent.call() 背后跑的是一整条 Runtime 链。

第 3 篇又看见了,Agent 运行里的最小消息单位不是字符串,而是 Msg

到第 4 篇,这三件事会自然收敛成一个结论。

Agent 这一层,最好只关心下面这些统一语义。

Agent 关心什么

例子

输入消息

List<Msg>

工具语义

List<ToolSchema>

生成控制

GenerateOptions

返回结果

ChatResponse / 最终再收敛成 Msg

它不该直接关心下面这些 provider 细节。

Provider 细节

为什么不该漏到 Agent 层

请求字段叫 messages 还是 input.messages

这是厂商协议差异

tool_choice 是字符串还是对象

这是厂商协议差异

strict 能不能传

这是厂商兼容性差异

多模态是不是要走另一套 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)

如果把这几个方法翻成人话,大概就是下面这个表。

方法

作用

format

把 AgentScope 的 Msg 翻译成 provider 请求消息

parseResponse

把 provider 返回结果翻回统一 ChatResponse

applyOptions

GenerateOptions 映射到各家请求参数

applyTools

把统一 ToolSchema 映射到各家 tool 定义

applyToolChoice

把工具调用策略映射到各家 tool choice 表达

注意这里最关键的一点。

Formatter 不是只处理“发出去的消息长什么样”。

它还负责。

- 请求前的参数映射

  • 工具能力映射

  • 返回后的响应解析

也就是说,它其实站在 Model 的边界上,承担的是一整次 provider 协议翻译。

不是一个字符串修饰器。

03-formatter-translation-bench.png

Model 是怎么把这层接进来的

再往下看 Model 这一层,你会发现它的抽象也刻意保持得很薄。

ChatModelBase 对外只暴露统一入口。

Flux<ChatResponse> stream(
List<Msg> messages, List<ToolSchema> tools, GenerateOptions options)

也就是说,所有具体模型,吃进来的都是同样三样东西。

输入

含义

messages

统一消息结构

tools

统一工具 schema

options

统一生成参数

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,到了不同模型那里,请求结构真的不是一回事。

从真实代码看,至少有这几个层面的差异。

04-provider-request-divergence.png

维度

OpenAI

DashScope

Ollama

请求主体

OpenAIRequest

DashScopeRequest

OllamaRequest

消息落点

messages

input.messages

messages

参数落点

直接挂 request 字段

主要进 parameters

主要进 options

多模态分支

同一套 formatter 内处理

可能切 formatMultiModal(...)

图片会转 base64

工具调用结构

tools + tool_choice

parameters.tools

tools + tool_choice,但消息结构不同

只看这张表,你大概会觉得这也还好。

但再往下翻一点,差异就更具体了。

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 里不能带 name

formatter 里修正

system message 要转成 user

formatter 里修正

不支持 tool 的 strict 参数

supportsStrict() 返回 false

thinking 模式要保留特定 reasoning_content

formatter 里修正

也就是说。

哪怕上层都叫“OpenAI-style request”,底层 provider 之间依然会有不兼容。

这时候最合理的做法,不是把这些兼容判断泄漏给 Agent。

而是让 formatter 在 provider 适配层里吞掉。

所以 Formatter 这层真正屏蔽的,不只是大类差异。

它还屏蔽了同类协议里的细碎脾气。

05-provider-quirks-patch.png

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 参数天然就不是统一平面的。

06-options-routing.png

业务真正该稳定的,是 Agent 这一层

如果把第 4 篇收回到工程实践,我会特别建议把边界记成这个表。

更适合关心什么

Agent

任务目标、消息流、工具协作、记忆与状态

Model

一次统一模型调用边界

Formatter

provider 请求/响应/工具/参数协议翻译

Provider SDK / HTTP Client

真正的网络调用和厂商接口细节

所以业务里更健康的写法通常是。

Agent 代码尽量只表达“我要做什么”
Model + Formatter 负责表达“这件事怎么跟具体模型说”

这也是为什么你回头看 BasicChatExampleModelRegistryExample,会觉得它们都在传达同一个方向。

框架希望你写的是统一 Agent 逻辑。

而不是每接一家模型,就复制一份新的 Agent。

把这一层压成一句话

如果你想把第 4 篇的核心只记一句,我觉得最贴源码的一句是。

Formatter 不是把消息“格式化一下”,而是把 AgentScope 的统一运行语义,翻译成不同 provider 各自能理解、能执行、能返回的完整协议。

你也可以记成下面这个链路。

阶段

统一输入/输出

适配层动作

Agent 发起推理

List<Msg> + List<ToolSchema> + GenerateOptions

不碰 provider 细节

Model.stream(...)

统一模型调用边界

把输入交给具体 model

Formatter.format(...)

Msg -> provider message

翻译消息结构

Formatter.applyOptions / applyTools / applyToolChoice

统一选项和工具语义

翻译请求参数与工具协议

Provider API 返回

provider response

formatter 解析

Formatter.parseResponse(...)

provider response -> ChatResponse

回到框架统一语义

所以从第 4 篇开始,你对 AgentScope Java 应该再多一层感觉。

它不是“支持很多模型”这么简单。

它是在试图把Agent 运行语义模型厂商协议

拆成两层东西。

这样 Agent 才有机会长成可迁移的业务单元,而不是跟着 provider 格式一起长歪。

下一篇我们就暂时离开适配层,回到 ReAct 本体。

不先看现成实现,直接用 Java 手写一个极简 Agent 循环。

继续阅读

基于全文检索与主题相似度