
前言
在进入正文之前,先交代一下这些文章的来龙去脉。
AgentForge 是一个面向 Java 开发者、从 LLM 最底层能力开始构建 的开源 Agent 框架。它不从高度封装的 Agent API 起步,而是先建立稳定、统一、可扩展的模型抽象,再逐层向上锻造 Tool、Memory、Middleware、Reasoning 与 Agent Runtime 等能力。
AgentForge = Agent + Forge:Agent 代表能理解目标、进行推理、调用工具并完成任务的智能体,Forge 则强调把原始智能持续加工、塑形、强化,最终锻造成真正可用的产品。
本系列《AgentForge 核心模块设计原理》沿着这条自底向上的路径,逐个模块拆解它的设计原理与实现细节。本文聚焦 统一 ChatModel 协议层设计——agentforge-model-api 如何用一套 Provider-neutral 接口封装统一出入参,以及不同协议如何基于它扩展。
- 开源仓库(GitHub):https://github.com/changluya/AgentForge
- 项目文档站:https://changluya.github.io/AgentForge/
- Gitee 镜像:https://gitee.com/changluJava/agent-forge
- 开源协议:MIT
git clone https://github.com/changluya/AgentForge.git
cd AgentForge
mvn clean install -DskipTests
如果这套「自底向上」的设计对你有帮助,欢迎到 GitHub 给 AgentForge 点一个 Star。
系列定位:本文属于「AgentForge 核心模块设计原理文章系列 / 01、model 模型协议层 / chatmodel」,是 ChatModel 类别原理课的第 01 篇。全文只讲两件事:
agentforge-model-api如何用一套 Provider-neutral 的接口 封装 ChatModel 的统一出入参;- OpenAI、Anthropic 等 不同协议模型如何基于这套标准 ChatModel 协议扩展落地。
Provider 的底层 wire 协议(字段名、Header、SSE、finish reason)单独放在 openai/ 与 anthropic/
目录,避免 Core 设计与厂商协议混杂。
目录
- 一、背景:为什么需要一层 ChatModel 协议层?
- 二、核心概念:一套标准 ChatModel 协议长什么样?
- 三、实现思路:agentforge-model-api 如何封装统一出入参
- 四、多协议扩展:不同协议模型如何基于标准协议扩展
- 五、实战:一次完整的多轮工具调用
- 六、实战用例:真实 curl 与返回结果
- 七、验证测试
- 八、边界、兼容与演进
- 九、总结
- 参考资料
一、背景:为什么需要一层 ChatModel 协议层?
1.1、从一个具体问题说起
在开发 AgentForge 的 Agent 层时,我们遇到了这样一个具体问题:
上层 ReAct Agent 的业务代码里,今天接的是 OpenAI,明天可能就要接 Anthropic,后天还要接一个 企业内部网关。怎样保证换模型时,Agent 主循环一行代码都不用改?
如果没有统一抽象,最常见的写法是这样的:
// 不推荐:Agent 里直接 new 供应商 SDK
OpenAiClient client = new OpenAiClient(apiKey);
ChatCompletionResult result = client.chatCompletion(messages, "gpt-4o-mini");
String text = result.getChoices().get(0).getMessage().getContent();
这段代码把三样东西牢牢绑死了:供应商 SDK 类型、供应商请求/响应结构、供应商字段命名。
一旦要换 Anthropic Messages(它的 system 是顶层字段、工具叫 tool_use、返回是 content block 数组),
Agent 层就得推倒重写。
1.2、如果直接绑定厂商 SDK 会怎样?
把「不抽象」的代价逐条列出来:
- 上层污染:Agent / Memory / Tool Calling 充斥
if (provider == OPENAI)之类的分支; - 测试困难:每次单测都要真实 API Key、真实网络,CI 跑不起来;
- 扩展昂贵:新增一家模型 = 改一处上层业务,回归范围不可控;
- 协议差异外泄:
finish_reason、prompt_tokens、tool_calls这些厂商专属概念渗透到业务代码。
重点:模型协议层的核心价值,不是「多支持几家模型」,而是 把厂商差异收敛到一个可替换的边界内。
1.3、这一层要达成的四个目标
因此 agentforge-model-api 的定位是:AgentForge 最底层、最稳定的模型访问抽象层。它不直接绑定
OpenAI、Anthropic 或任何第三方 Java SDK,只定义统一的请求、响应、消息、流式回调、HTTP 传输和异常边界。
四个设计目标:
- Provider-neutral:Agent、Memory、Tool Calling 等上层模块只依赖契约层(
agentforge-model-api) 与 core,不依赖任何供应商 SDK; - 协议适配下沉:OpenAI / Anthropic 的字段名、Header、SSE、finish reason 等差异全部由 Provider 模块消化;
- Java 8 API 兼容:默认开发环境可用 JDK 17,但公共 LLM API 避免 Java 9+ 语言特性和集合工厂;
- 为后续 Agent 能力预留扩展点:消息类型、
customParameters、metadata、HttpTransport都保留扩展空间。
注意:这里的 Java 8 是指 公共 API 的语法与 bytecode 兼容,并不代表项目只能在 JDK 8 上构建, 内部实现仍推荐运行在 JDK 17。
1.4、从请求到响应:协议层全景
标准 ChatModel 协议贯穿始终,整条调用链如下:
Agent / Application
│
▼
ChatModel / StreamingChatModel <- 统一入口:一套接口
│
▼
ChatRequest + ChatMessage + ChatRequestParameters <- 统一入参
│
▼
Provider Adapter(OpenAI / Anthropic / ...) <- 只做协议转换
│
▼
HttpTransport <- 统一传输 SPI
│
▼
Provider HTTP API -> Provider JSON / SSE <- 厂商差异被隔离在 Provider 内
│
▼
ChatResponse + TokenUsage + FinishReason <- 统一出参
一句话概括:上面看到的永远是 ChatRequest 进、ChatResponse 出;厂商协议只在 Provider 模块内部存在。
二、核心概念:一套标准 ChatModel 协议长什么样?
在写代码之前,先回答一个问题:所谓「标准 ChatModel 协议」,到底由哪些部分构成?
答案可以拆成四层:模块分层、包结构、核心契约、以及 Provider 必须遵守的行为约束。
2.1、模块分层与依赖方向
agentforge-model
├── agentforge-model-api # 契约层:接口 + req/vo,零第三方依赖
│ ├── chat.ChatModel / StreamingChatModel
│ ├── chat.message.* # ChatMessage 体系 + Content 多模态基座
│ ├── chat.request.* # ChatRequest / ChatRequestParameters / ToolChoice
│ ├── chat.response.* # ChatResponse / StreamingChatResponseHandler / TokenUsage / FinishReason
│ ├── tool / tool.spec / tool.execution / tool.error
│ ├── http.* # HttpRequest / HttpResponse / HttpTransport / StreamingHttpResponseHandler
│ └── exception.ModelException
├── agentforge-model-core # 无厂商依赖的默认实现与执行引擎
│ ├── chat.request.DefaultChatRequestParameters
│ ├── http.JdkHttpTransport # 基于 HttpURLConnection
│ ├── internal.json.Json # 内部 JSON 能力(非公共 API)
│ └── tool.execution.ToolService / DefaultToolExecutor / ToolExecutionRequestUtil
│ tool.spec.ToolSpecifications
├── agentforge-model-openai # OpenAI Chat Completions 协议实现
│ ├── OpenAiChatModel / OpenAiStreamingChatModel
│ └── OpenAiMessages # 包内共享 wire helper(非公共 API)
├── agentforge-model-anthropic # Anthropic Messages 协议实现
│ ├── AnthropicChatModel / AnthropicStreamingChatModel
│ └── AnthropicProtocol # Blocking / Streaming 共享 wire mapping
└── agentforge-model-registry # 开箱即用的模型工厂
├── LlmFactory / LlmEnum / LlmBasicConfig
└── models.IModel / BaseModel / OpenAiModel / AnthropicModel
依赖方向严格单向、无环:
agentforge-model-api (契约,无依赖)
▲
┌───────────┼───────────────┐
│ │ │
model-core model-openai model-anthropic
▲ ▲ ▲
└───────────┴───────────────┘
│
agentforge-model-registry
| 模块 | 职责 | 是否含厂商 SDK |
|---|---|---|
agentforge-model-api |
稳定契约:接口、req/vo、消息、Tool 契约、HTTP SPI、异常 | 否 |
agentforge-model-core |
无厂商依赖的默认实现:参数合并、JDK 传输、JSON、Tool 执行引擎 | 否 |
agentforge-model-openai |
OpenAI 协议映射 | 否(手写 wire,不引入 SDK) |
agentforge-model-anthropic |
Anthropic 协议映射 | 否(手写 wire,不引入 SDK) |
agentforge-model-registry |
按配置装配 Provider,对上层提供工厂 | 否 |
重点:Core 只定义稳定、Provider-neutral 的 LLM 边界;Provider 模块只负责协议转换,绝不把供应商 SDK 类型泄露到上层。
2.2、契约层包结构
契约层是本文重点,单独展开:
cloud.changlu.agentforge.model
├── chat
│ ├── ChatModel.java
│ ├── StreamingChatModel.java
│ ├── message
│ │ ├── ChatMessage.java / ChatMessageType.java / AbstractTextMessage.java
│ │ ├── SystemMessage.java / UserMessage.java / AiMessage.java / CustomMessage.java
│ │ ├── ToolExecutionRequest.java / ToolExecutionResultMessage.java
│ │ └── Content.java / ContentType.java / TextContent.java
│ ├── request
│ │ ├── ChatRequest.java
│ │ ├── ChatRequestParameters.java
│ │ └── ToolChoice.java
│ └── response
│ ├── ChatResponse.java / StreamingChatResponseHandler.java
│ ├── TokenUsage.java
│ └── FinishReason.java
├── tool
│ ├── Tool.java / P.java / ReturnBehavior.java / ToolExecutor.java
│ ├── spec
│ │ ├── ToolSpecification.java
│ │ └── ToolParameters.java
│ ├── execution
│ │ ├── ToolExecution.java
│ │ └── ToolExecutionResult.java
│ └── error
│ ├── ToolArgumentsException.java / ToolExecutionException.java
│ ├── ToolArgumentsErrorHandler.java / ToolExecutionErrorHandler.java
│ └── ToolErrorContext.java / ToolErrorHandlerResult.java
├── http
│ ├── HttpRequest.java / HttpResponse.java
│ ├── HttpTransport.java
│ └── StreamingHttpResponseHandler.java
└── exception
└── ModelException.java
agentforge-model-core 放置默认实现与执行引擎(不在 api 中):
cloud.changlu.agentforge.model
├── chat.request.DefaultChatRequestParameters # 参数默认实现 + merge
├── http.JdkHttpTransport # HttpURLConnection 传输实现
├── internal.json.Json # 内部 JSON(非公共 API)
└── tool
├── spec.ToolSpecifications # @Tool 方法 -> ToolSpecification
└── execution.ToolService / DefaultToolExecutor / ToolExecutionRequestUtil
注意:
api是「契约」,core是「默认实现」。上层依赖 api 拿能力,跑起来时通常引入 core。 千万不要把JdkHttpTransport、DefaultChatRequestParameters、Json这些实现类当成公共 API。
2.3、标准协议由哪些核心契约组成?
标准 ChatModel 协议
├── 入口契约:ChatModel / StreamingChatModel
├── 入参契约:ChatRequest + ChatRequestParameters(+ ToolChoice)
├── 消息契约:ChatMessage 体系(System / User / Ai / ToolResult / Custom + Content)
├── 出参契约:ChatResponse + TokenUsage + FinishReason
├── 流式契约:StreamingChatResponseHandler(partial / thinking / complete / error)
├── 传输契约:HttpTransport + StreamingHttpResponseHandler
└── 异常契约:ModelException
三、实现思路:agentforge-model-api 如何封装统一出入参
本章逐层拆解契约层的接口封装与实现。核心思想是:入参统一收敛为 ChatRequest,出参统一收敛为
ChatResponse,同步与流式共享同一套消息与响应模型。
3.1、ChatModel:同步模型的最低稳定边界
@FunctionalInterface
public interface ChatModel {
ChatResponse chat(ChatRequest chatRequest);
default String chat(String userMessage) { ... }
default ChatResponse chat(ChatMessage... messages) { ... }
default ChatResponse chat(List<? extends ChatMessage> messages) { ... }
}
设计要点:
- Provider 只需要实现一个主入口
ChatResponse chat(ChatRequest); - 三个便利方法(单条字符串、变长消息、消息列表)都是
default,最终都收敛成ChatRequest, Provider 不需要重复实现多套入口; @FunctionalInterface让简单的模型/测试实现可以直接用 Lambda。
重点:便利方法收敛到
ChatRequest,是「一个 Provider 只实现一个方法」的关键。若每个便利方法都 要求 Provider 各写一遍,Provider 数量一多就会失控。
3.2、StreamingChatModel:流式为什么用回调?
public interface StreamingChatModel {
void chat(ChatRequest chatRequest, StreamingChatResponseHandler handler);
default void chat(String userMessage, StreamingChatResponseHandler handler) { ... }
default void chat(StreamingChatResponseHandler handler, ChatMessage... messages) { ... }
default void chat(List<? extends ChatMessage> messages, StreamingChatResponseHandler handler) { ... }
}
为什么不用 Flow、Reactive Streams 或 Reactor? 我们来对比几种方案:
| 方案 | 优点 | 弊端说明 |
|---|---|---|
| 回调式(当前选型) | Java 8 兼容、零额外依赖、实现直观 | 需要自己处理回调时序与异常收敛 |
java.util.concurrent.Flow |
JDK 9+ 标准 | 公共 API 被迫升级到 JDK 9+,破坏 Java 8 兼容 |
| Reactor / RxJava | 背压、组合算子强大 | 最底层协议层强依赖响应式框架,依赖变重 |
CompletableFuture<Stream> |
简单 | 流式增量语义表达不自然,取消/错误处理繁琐 |
最终选择回调式,理由是:协议层要轻,响应式框架的复杂度不该下沉到最底层。
流式生命周期被规范为:
开始请求
│
├── onPartialThinking(partialThinking) 0..N 次 (推理模型的思考增量,可选)
│
├── onPartialResponse(partialText) 0..N 次 (可见文本增量)
│
├── onCompleteResponse(response) 成功时恰好 1 次
│
└── onError(error) 失败时 1 次
public interface StreamingChatResponseHandler {
void onPartialResponse(String partialResponse);
default void onPartialThinking(String partialThinking) {}
void onCompleteResponse(ChatResponse completeResponse);
void onError(Throwable error);
}
注意:
onPartialThinking提供默认空实现,专门承接 DeepSeek 风格reasoning_content、Anthropicthinking块等推理增量。这样新增回调也不会破坏已有实现(零改动可编译)。
问题引导:流式和非流式的结果模型会不会分裂成两套?
不会。onCompleteResponse 返回的是已经聚合完成的标准 ChatResponse,因此流式与非流式最终复用统一的
响应模型;Provider 负责在流结束前把工具调用增量、token usage、finish reason 全部聚合进 ChatResponse。
3.3、ChatRequest:不可变统一入参
public final class ChatRequest {
private final List<ChatMessage> messages;
private final ChatRequestParameters parameters;
}
约束:
messages必须至少包含一条消息,否则构造时抛IllegalArgumentException;- Builder 支持
message(单条)与messages(集合); - 构建后对消息列表做不可变包装(
Collections.unmodifiableList); parameters允许为空,此时全部使用 Provider Model Builder 的 model-level 默认参数。
ChatRequest request = ChatRequest.builder()
.message(SystemMessage.from("You are a Java assistant."))
.message(UserMessage.from("Explain CompletableFuture."))
.parameters(DefaultChatRequestParameters.builder()
.modelName("provider-model")
.temperature(0.2)
.maxTokens(1024)
.build())
.build();
3.4、ChatRequestParameters:参数契约与合并规则
| Core 字段 | Java 类型 | 含义 |
|---|---|---|
modelName() |
String |
Provider 模型 ID |
temperature() |
Double |
采样随机度,是否支持由 Provider 决定 |
maxTokens() |
Integer |
最大输出 Token 数 |
topP() |
Double |
nucleus sampling 参数 |
stopSequences() |
List<String> |
停止序列 |
customParameters() |
Map<String,Object> |
Provider 特有扩展字段 |
tools() |
List<ToolSpecification> |
本次请求允许模型调用的工具声明 |
toolChoice() |
ToolChoice |
AUTO / NONE / REQUIRED / SPECIFIC |
toolChoiceName() |
String |
SPECIFIC 时指定的工具名 |
前六个字段是抽象方法,后三个是接口默认方法:
public interface ChatRequestParameters {
String modelName();
Double temperature();
Integer maxTokens();
Double topP();
List<String> stopSequences();
Map<String, Object> customParameters();
default List<ToolSpecification> tools() { return null; }
default ToolChoice toolChoice() { return null; }
default String toolChoiceName() { return null; }
}
重点:新增能力优先用接口默认方法。这样即便已有第三方实现了
ChatRequestParameters,也能零改动 继续编译。这是契约持续演进而不破坏兼容的关键手法。
Provider 面对通用参数有三种合法处理方式:
Core 通用参数
├── Provider 原生支持 -> 映射成供应商字段
├── Provider 不支持 -> 忽略或显式校验失败
└── Provider 特有能力 -> 通过 customParameters 扩展
3.4.1、参数合并规则
DefaultChatRequestParameters(core 模块)负责 model-level defaults 与 request-level overrides 合并:
Model Builder defaults
│
▼
DefaultChatRequestParameters.merge(defaults, overrides)
▲
│
Request overrides
规则是:请求级非空值覆盖模型级默认值,实现为「先 apply defaults,再 apply overrides」,每个字段仅在
非空时写入;customParameters 采用 Map 合并,请求级同名 key 覆盖模型级同名 key。
public static DefaultChatRequestParameters merge(
ChatRequestParameters defaults, ChatRequestParameters overrides) {
Builder builder = builder();
apply(builder, defaults);
apply(builder, overrides);
return builder.build();
}
示例:
// model-level
OpenAiChatModel.builder()
.modelName("model-a")
.temperature(0.7)
.maxTokens(2048)
.build();
// request-level 只覆盖温度
DefaultChatRequestParameters.builder().temperature(0.1).build();
// 最终: modelName=model-a, maxTokens=2048, temperature=0.1
3.5、ChatResponse / TokenUsage / FinishReason:统一出参
public final class ChatResponse {
private final AiMessage aiMessage;
private final TokenUsage tokenUsage;
private final FinishReason finishReason;
private final Map<String, Object> metadata;
}
| 字段 | 是否必须 | 说明 |
|---|---|---|
aiMessage |
是 | 最终模型文本 / 思考 / 工具调用消息 |
tokenUsage |
否 | 标准化 Token 统计 |
finishReason |
否 | 标准化结束原因 |
metadata |
否 | 保留 Provider 原始但通用层不需要理解的元数据 |
重点:Provider 的原始 JSON 不直接向上泄露,但关键 Provider 信息可以保留到
metadata中。例如 OpenAI 保存id/model/created,Anthropic 保存id/model/type。
TokenUsage 统一三字段:
public final class TokenUsage {
long inputTokens;
long outputTokens;
long totalTokens;
}
OpenAI: prompt_tokens -> inputTokens, completion_tokens -> outputTokens, total_tokens -> totalTokens
Anthropic: input_tokens -> inputTokens, output_tokens -> outputTokens, input + output -> totalTokens
FinishReason 统一枚举:
public enum FinishReason { STOP, LENGTH, TOOL_EXECUTION, CONTENT_FILTER, OTHER }
OpenAI stop/length/tool_calls/content_filter -> STOP/LENGTH/TOOL_EXECUTION/CONTENT_FILTER
Anthropic end_turn|stop_sequence/max_tokens/tool_use -> STOP/LENGTH/TOOL_EXECUTION
3.6、ChatMessage:消息体系
public interface ChatMessage {
ChatMessageType type();
default String text() {
throw new UnsupportedOperationException(
"Message type " + type() + " does not expose a text payload");
}
}
消息类型由 ChatMessageType 枚举统一登记,并绑定到具体实现类:
| 类型 | 实现类 | 当前语义 |
|---|---|---|
SYSTEM |
SystemMessage |
系统指令(继承 AbstractTextMessage) |
USER |
UserMessage |
用户输入,可携带 name 与多模态 List<Content> |
AI |
AiMessage |
模型输出 / 历史助手消息,可携带 thinking 与 toolExecutionRequests |
TOOL_EXECUTION_RESULT |
ToolExecutionResultMessage |
工具执行结果 |
CUSTOM |
CustomMessage |
Provider 特有消息 |
3.6.1、AiMessage
public final class AiMessage implements ChatMessage {
private final String text;
private final String thinking;
private final List<ToolExecutionRequest> toolExecutionRequests;
private final Map<String, Object> attributes;
}
text()允许为null(纯工具调用时);thinking()保留推理内容字段位;hasToolExecutionRequests()/toolExecutionRequests()暴露工具调用;attributes()提供 Provider 扩展位,防御性复制为只读 Map。
3.6.2、UserMessage 与 Content 多模态基座
public final class UserMessage implements ChatMessage {
private final String name;
private final List<Content> contents;
}
name可为空,Provider 可忽略;contents至少一条,构建后不可变;Content是多模态内容基接口,type()返回ContentType;当前只落地TextContent(ContentType.TEXT), 后续ImageContent/AudioContent/VideoContent/PdfFileContent可在不改消息语义的前提下扩展。
保留的历史兼容访问器:
| 方法 | 语义 |
|---|---|
text() |
单一 TextContent 时返回文本;否则抛 UnsupportedOperationException |
hasSingleText() |
是否只有一条 TextContent |
singleText() |
只有一条 TextContent 时返回,否则抛异常 |
UserMessage.from("hello");
UserMessage.from("alice", "hello");
UserMessage.from(TextContent.from("describe this"), TextContent.from("..."));
UserMessage.from("alice", Arrays.<Content>asList(TextContent.from("hi")));
3.6.3、工具消息与自定义消息
ToolExecutionRequest:模型发起的一次工具调用,字段id/name/arguments(原始 JSON 字符串);ToolExecutionResultMessage:工具执行结果,字段id/toolName/text/isError/attributes,id必须与被调用的ToolExecutionRequest.id()一致,用于关联;CustomMessage:承载 Provider 特有、框架无法预定义结构的消息,只暴露不可变的attributes()。 Provider 若不支持应显式拒绝,而不是静默降级为普通 user message。
3.7、HttpTransport:传输与协议解耦
public interface HttpTransport {
HttpResponse execute(HttpRequest request) throws IOException;
default void executeStreaming(HttpRequest request, StreamingHttpResponseHandler handler) {
handler.onError(new UnsupportedOperationException(
"Streaming HTTP is not supported by " + getClass().getName()));
}
}
Provider 只依赖 HttpTransport,因此可以:
- 默认使用
JdkHttpTransport(基于HttpURLConnection); - 测试时注入 Fake Transport(这是不依赖真实 API Key 的关键);
- 后续适配 OkHttp、Apache HttpClient 或企业内部网关;
- 完全不修改
ChatModelAPI。
底层流处理接口只认识 HTTP 行,不解析 SSE:
public interface StreamingHttpResponseHandler {
void onOpen(int statusCode, Map<String, List<String>> headers);
void onLine(String line);
void onComplete();
void onError(Throwable error);
}
HTTP line / SSE event
│
▼
Provider Stream Parser <- 厂商差异在这里消化
│
▼
partial text / thinking / ChatResponse
重点:SSE 是供应商协议的一部分,必须由 OpenAI / Anthropic Provider 自己解析。因此未来 Anthropic SSE 即使事件格式与 OpenAI 完全不同,也不需要修改 Core。
3.8、ModelException 与 internal.json.Json
public class ModelException extends RuntimeException {
private final Integer statusCode;
private final String responseBody;
}
它既可以表达 HTTP 4xx / 5xx,也可以表达网络 IOException、JSON 解析失败、Provider 响应结构不符合预期。
HTTP 非 2xx 时保留 message / statusCode / responseBody,便于后续统一做 retry、rate-limit、日志和可观测性。
JSON 能力放在 internal 包中,意味着它不是 AgentForge 对外公共 API:
Java Map/List -> JSON String
JSON String -> Map/List
注意:未来即使内部替换 JSON 实现,也不应影响
ChatModel、ChatRequest、ChatResponse等公共契约。
四、多协议扩展:不同协议模型如何基于标准协议扩展
这是本文的核心。标准协议定好之后,OpenAI、Anthropic 以及未来任意协议,如何基于它扩展?
4.1、扩展铁律
- 只新增 Provider 模块,不修改
agentforge-model-api的既有契约; - 只实现
ChatModel/StreamingChatModel,不重新定义请求/响应类型; - 厂商 wire 细节必须留在 Provider 模块内部(包私有 helper);
- 需要新增通用能力时,优先给接口加默认方法;
- 厂商私有字段走
customParameters/metadata/attributes,不污染 Core。
4.2、Provider 模块骨架
agentforge-model-xxx
├── pom.xml # 依赖 agentforge-model-api(+ core)
└── src/main/java/.../xxx
├── XxxChatModel implements ChatModel
├── XxxStreamingChatModel implements StreamingChatModel
└── XxxProtocol / XxxMessages # 包私有 wire helper(Blocking/Streaming 共享)
现有模块的 pom 依赖:
agentforge-model-openai -> agentforge-model-api + agentforge-model-core
agentforge-model-anthropic -> agentforge-model-api + agentforge-model-core
4.3、实战代码:五步实现一个 Provider
无论什么协议,实现过程固定为五步:
1. merge 默认参数和请求参数
2. ChatRequest -> Provider Request JSON
3. HttpTransport 执行请求
4. Provider Response JSON -> ChatResponse
5. Provider Error -> ModelException
以 OpenAiChatModel 为骨架:
public final class OpenAiChatModel implements ChatModel {
@Override
public ChatResponse chat(ChatRequest chatRequest) {
// 1. merge
DefaultChatRequestParameters parameters =
DefaultChatRequestParameters.merge(defaultParameters, chatRequest.parameters());
requireModelName(parameters.modelName());
// 2. ChatRequest -> Provider Request JSON
Map<String, Object> payload = buildPayload(chatRequest, parameters);
HttpRequest request = HttpRequest.builder()
.url(baseUrl + "/chat/completions")
.header("Content-Type", "application/json")
.body(Json.stringify(payload))
.build();
try {
// 3. execute
HttpResponse response = httpTransport.execute(request);
if (!response.isSuccessful()) {
throw new ModelException("OpenAI request failed with HTTP "
+ response.statusCode(), response.statusCode(), response.body());
}
// 4. Provider Response JSON -> ChatResponse
return parseResponse(response.body());
} catch (IOException e) {
// 5. Provider Error -> ModelException
throw new ModelException("OpenAI request failed", e);
}
}
}
Provider Builder 负责持有:baseUrl / apiKey / anthropicVersion / modelName / 采样参数 /
customHeaders / httpTransport / 超时,并在构造时把这些参数收进 model-level 的
DefaultChatRequestParameters。
4.4、请求侧映射对比
问题引导:OpenAI 和 Anthropic 差异到底有多大?看这张对照表就清楚了。
| Core | OpenAI (Chat Completions) | Anthropic (Messages) |
|---|---|---|
SystemMessage |
messages[].role=system |
顶层 system 字段(多个 system 用空行拼接) |
UserMessage(单文本) |
messages[].role=user, content=String |
messages[].role=user, content=String |
UserMessage(多 Content) |
content=[{type:text,text}] |
content=[{type:text,text}] |
AiMessage(含工具调用) |
role=assistant + tool_calls[] |
role=assistant + content=[{type:tool_use,...}] |
ToolExecutionResultMessage |
role=tool + tool_call_id |
role=user + content=[{type:tool_result,...}] |
customParameters |
直接铺到 payload 顶层 | 直接铺到 payload 顶层 |
tools |
tools[].function.parameters |
tools[].input_schema |
toolChoice |
"auto"/"none"/"required"/{type:function,...} |
{type:auto/none/any/tool,...} |
temperature/maxTokens/topP/stop |
temperature/max_tokens/top_p/stop |
temperature/max_tokens/top_p/stop_sequences |
协议差异点,正是扩展设计要隔离的地方:
- system 位置不同:OpenAI 放在消息数组里,Anthropic 提为顶层字段;
- tool result 角色不同:OpenAI 是
role=tool,Anthropic 是role=user下的tool_result块; - tool_use 入参不同:OpenAI
arguments是 JSON 字符串,Anthropicinput是对象,需要序列化对齐。
4.5、响应侧映射对比
OpenAI assistant message Anthropic assistant message
tool_calls[] {id,type, content[] {type:"tool_use",
function{name,arguments}} id,name,input{...}}
│ │
▼ ▼
ToolExecutionRequest ToolExecutionRequest
└──────────────┬─────────────────────────┘
▼
AiMessage.toolExecutionRequests()
要点:
- OpenAI
finish_reason=tool_calls(旧版function_call)与 Anthropicstop_reason=tool_use统一映射为FinishReason.TOOL_EXECUTION; - 纯工具调用时
content可能为null,此时AiMessage.text()返回null; - Anthropic
tool_use.input是对象,序列化成 JSON 字符串存入arguments,与 OpenAI 对齐; - 原始
id/model/created/type进ChatResponse.metadata。
4.6、流式聚合
HttpTransport.executeStreaming(request, state)
│ onOpen / onLine / onComplete / onError
▼
Provider Stream Parser(实现 StreamingHttpResponseHandler)
│
├── 文本增量 -> handler.onPartialResponse(...)
├── 思考增量 -> handler.onPartialThinking(...)
└── 工具增量 -> 内部按 index 聚合
│
▼ onCompleteResponse(聚合后的 ChatResponse)
文本与思考实时回调,工具调用只在最终响应暴露,与 LangChain4j 保持一致。
OpenAI delta.tool_calls[] 按 index 分桶:
chunk 1: {index:0, id:"call_1", function:{name:"getWeather", arguments:""}}
chunk 2: {index:0, function:{arguments:"{\"city\":"}}
chunk 3: {index:0, function:{arguments:"\"hangzhou\"}"}}
│
▼ merge by index
ToolExecutionRequest(id=call_1, name=getWeather, arguments={"city":"hangzhou"})
- 多数 OpenAI 兼容端点带
index;当index缺省时,以「出现新的id」作为新调用起点做兜底分桶; - 同一
index的多个arguments片段按到达顺序拼接,原始 JSON 文本不重新格式化。
Anthropic 以 content_block_start(tool_use)开块:
content_block_start (index=1, tool_use, id=toolu_1, name=get_weather)
content_block_delta (index=1, input_json_delta: '{"city":')
content_block_delta (index=1, input_json_delta: '"hangzhou"}')
│
▼ accumulate by content block index
ToolExecutionRequest(id=toolu_1, name=get_weather, arguments={"city":"hangzhou"})
4.7、共享 wire helper
每个 Provider 建议抽出一个包私有的 wire helper,供 Blocking / Streaming 复用:
OpenAiMessages(package-private, final)
├── serialize(messages) ChatMessage -> OpenAI messages[]
├── serializeTools(tools) ToolSpecification -> tools[]
├── toolChoice(parameters) ToolChoice -> tool_choice
├── parseToolCalls(toolCalls) tool_calls[] -> ToolExecutionRequest
└── extractContent(content) String | content[] -> text
AnthropicProtocol(package-private, final)
├── collectSystemMessages(messages) system 顶层字段
├── serializeMessages(messages) messages[]
├── serializeTools(tools) input_schema
├── extractToolUses(contentBlocks) tool_use -> ToolExecutionRequest
└── extractText(contentBlocks) text block -> text
重点:它是 Provider 的实现细节,不是公共 API,因此不会污染
agentforge-model-api。
4.8、模型注册与工厂
问题引导:上层要接模型时,难道还要 new 具体的 OpenAiChatModel 吗?
不用。agentforge-model-registry 在 Provider 之上提供开箱即用的装配能力:
LlmBasicConfig(provider / url / modelName / apiKey / props)
│
▼
LlmFactory.buildChatModel(config) / buildStreamChatModel(config)
│ LlmEnum.of(providerCode) -> IModel
▼
OpenAiModel / AnthropicModel 实现 IModel
│
▼
OpenAiChatModel / AnthropicChatModel (底层仍是标准 ChatModel)
public interface IModel {
ChatModel buildChatModel(LlmBasicConfig llmBasicConfig);
StreamingChatModel buildStreamChatModel(LlmBasicConfig llmBasicConfig);
}
LlmEnum 登记「providerCode -> IModel 实现类」,LlmFactory 反射实例化并委托构建,例如
OPENAI(1, "OpenAI", OpenAiModel.class)、ANTHROPIC(2, "Anthropic", AnthropicModel.class)。
注意:
IModel注释已预留:后续EmbeddingModel等其他模型类别也在这里继续扩展。这意味着 「标准协议 + Provider 扩展」的模式会沿着模型类别继续复制。
4.9、新增 Provider 检查清单
[ ] 新建 agentforge-model-xxx 模块,依赖 api(+ core)
[ ] XxxChatModel implements ChatModel
[ ] XxxStreamingChatModel implements StreamingChatModel
[ ] XxxProtocol / XxxMessages 包私有 wire helper(Blocking/Streaming 共享)
[ ] 五步实现:merge -> request -> transport -> response -> exception
[ ] 映射 FinishReason / TokenUsage / metadata / tool 调用
[ ] 把 model 参数收进 DefaultChatRequestParameters 默认值
[ ] 补充单测:纯文本 / 工具调用 / 流式聚合 / 错误 / 参数合并
[ ] 在 agentforge-model-registry 增加 IModel 实现并登记 LlmEnum
五、实战:一次完整的多轮工具调用
5.1、为什么还需要一层 Tool layer?
问题引导:Provider 已经能解析 tool_calls 了,为什么 Model API 里还有一套 ToolService?
因为两者解决的是不同问题:
- Provider 层解决「模型协议里如何表达工具调用」(wire 映射);
- Tool layer 解决「上层 Agent 如何把普通 Java 方法变成可被 LLM 调用的工具,并自动驱动多轮执行」。
它对应包 cloud.changlu.agentforge.model.tool,拆分为顶层 + 三个子包:
cloud.changlu.agentforge.model.tool
├── Tool / P / ReturnBehavior / ToolExecutor # 核心契约
├── spec
│ ├── ToolSpecification # name/description/parameters/strict
│ ├── ToolParameters # JSON-Schema 风格(纯 Map,无第三方依赖)
│ └── ToolSpecifications(core) # @Tool 方法 -> ToolSpecification
├── execution
│ ├── ToolExecution / ToolExecutionResult(api) # 一次执行(请求+结果+耗时)/ 值对象
│ ├── ToolService / DefaultToolExecutor(core) # 注册 + 推理/执行循环 / 反射执行器
│ └── ToolExecutionRequestUtil(core) # arguments JSON -> Map
└── error
├── ToolArgumentsException / ToolExecutionException
└── ToolArgumentsErrorHandler / ToolExecutionErrorHandler
ToolErrorContext / ToolErrorHandlerResult
5.2、快速构建工具
用 @Tool 标注方法,交给 ToolService 注册:
public class WeatherTools {
@Tool(value = "Returns the weather for the given city")
public String getWeather(@P("city name") String city) {
return "Weather in " + city + ": 22C sunny";
}
}
ToolService toolService = new ToolService();
toolService.tools(Arrays.asList(new WeatherTools())); // 扫描全部 @Tool 方法
ToolChatResult result = toolService.chat(model, parameters, messages);
System.out.println(result.finalResponse().aiMessage().text());
ToolSpecifications 把方法名 / @P 参数名 / 类型自动转成 ToolSpecification + ToolParameters,
与 Provider 一侧的 tools() / toolChoice() 天然对接。
5.3、ToolExecutor 与执行循环
@FunctionalInterface
public interface ToolExecutor {
String execute(ToolExecutionRequest request, Object memoryId);
default ToolExecutionResult executeWithResult(ToolExecutionRequest request, Object memoryId) { ... }
}
DefaultToolExecutor(core)是默认反射实现:把arguments()JSON 绑定到@Tool方法参数并调用;String-> 原样返回;void-> 字面量"Success";- 其它 ->
Json.stringify(...)。
- 也可用 Lambda 提供自定义
ToolExecutor。
ToolService.chat(...) 的循环:
1. 携带已注册 tools 调用 ChatModel
2. 若 aiMessage.hasToolExecutionRequests():
a. 逐个执行工具(找不到 executor -> 默认抛异常)
b. 把每个 ToolExecutionResult 转成 ToolExecutionResultMessage(id 关联)
c. 追加到消息列表
d. 若 ReturnBehavior 要求立即返回 -> 停止
e. 否则用新消息继续下一轮
3. 若无工具调用 -> 返回最终 ChatResponse + 全部 ToolExecution
- 默认
maxToolCallingRoundTrips = 100,防止死循环; ReturnBehavior:TO_LLM(默认)、IMMEDIATE、IMMEDIATE_IF_LAST;- 任意工具出错都会强制再跑一轮,让 LLM 看到错误并纠正重试。
5.4、错误处理
| 异常 | 触发点 | 默认处理 |
|---|---|---|
ToolArgumentsException |
参数 JSON 无法解析 / 类型不符 / 缺必填参数 | 抛异常(RETHROW) |
ToolExecutionException |
工具方法执行失败 | 以 ToolErrorHandlerResult.text(...) 回给 LLM |
可通过 argumentsErrorHandler(...) / executionErrorHandler(...) 定制:要么返回
ToolErrorHandlerResult.text(msg)(回给 LLM),要么直接抛异常(终止调用)。
5.5、与 Function Calling 协议层的关系
ToolService.chat() <- 上层 Agent 入口(复用本章 Tool layer)
│
ChatModel / ChatRequest / ChatResponse <- 第三/四章标准协议
│
OpenAI / Anthropic Adapter <- 第四章多协议扩展
│
AiMessage.toolExecutionRequests() <- 模型给出的工具调用
│
ToolService 执行 -> ToolExecutionResultMessage 回填
六、实战用例:真实 curl 与返回结果
本章把最常见的调用形态——非流式 / 流式 与 纯文本 / Function Calling / 思考过程——用真实 curl 与
返回结果完整走一遍,方便对照第三章的 ChatRequest / ChatResponse 映射与第四章的协议转换。其中
OpenAI 侧额外覆盖「工具调用」与「推理模型思考过程」两类返回。
说明:下方返回值为 真实协议结构(
id、时间戳、token 数会随实际调用变化);仓库中提供了可直接运行的openai/verify-openai-chat.sh与anthropic/verify-anthropic-chat.sh用于真实环境验证。
6.1、OpenAI
本节覆盖 4 类 OpenAI 返回:非流式纯文本、非流式 Function Calling、流式思考过程、流式 Function Calling。
6.1.1、非流式(纯文本)
curl:
curl https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4o-mini",
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "用一句话解释什么是 CompletableFuture。"}
],
"temperature": 0.7,
"max_tokens": 256
}'
返回(节选):
{
"id": "chatcmpl-9xYzAbC123",
"object": "chat.completion",
"created": 1726200000,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "CompletableFuture 是 Java 8 引入的异步编程工具,用来表示一个尚未完成、但未来会给出结果的计算。"
},
"finish_reason": "stop"
}
],
"usage": { "prompt_tokens": 28, "completion_tokens": 38, "total_tokens": 66 }
}
AgentForge 侧映射:
choices[0].message.content -> AiMessage.text()
choices[0].finish_reason -> FinishReason.STOP
usage.prompt_tokens -> TokenUsage.inputTokens = 28
usage.completion_tokens -> TokenUsage.outputTokens = 38
usage.total_tokens -> TokenUsage.totalTokens = 66
id / model / created -> ChatResponse.metadata
对应调用:
ChatModel model = OpenAiChatModel.builder()
.apiKey(System.getenv("OPENAI_API_KEY"))
.modelName("gpt-4o-mini")
.temperature(0.7)
.maxTokens(256)
.build();
ChatResponse response = model.chat(ChatRequest.builder()
.message(SystemMessage.from("You are a helpful assistant."))
.message(UserMessage.from("用一句话解释什么是 CompletableFuture。"))
.build());
System.out.println(response.aiMessage().text());
System.out.println(response.tokenUsage().totalTokens()); // 66
System.out.println(response.finishReason()); // STOP
6.1.2、非流式(Function Calling)
curl(携带 tools 与 tool_choice):
curl https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4o-mini",
"messages": [
{"role": "user", "content": "杭州今天天气怎么样?"}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的当前天气",
"parameters": {
"type": "object",
"properties": { "city": { "type": "string", "description": "城市名" } },
"required": ["city"]
}
}
}
],
"tool_choice": "auto"
}'
返回(模型决定调用工具,content 为 null):
{
"id": "chatcmpl-9xYzAbC456",
"object": "chat.completion",
"created": 1726200100,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"杭州\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
],
"usage": { "prompt_tokens": 62, "completion_tokens": 18, "total_tokens": 80 }
}
AgentForge 侧映射:
message.tool_calls[].id -> ToolExecutionRequest.id = "call_abc123"
message.tool_calls[].function.name -> ToolExecutionRequest.name = "get_weather"
message.tool_calls[].function.arguments -> ToolExecutionRequest.arguments = {"city":"杭州"}
message.content = null -> AiMessage.text() = null
finish_reason=tool_calls -> FinishReason.TOOL_EXECUTION
usage.* -> TokenUsage
对应调用与「工具结果回填」:
ToolSpecification weather = ToolSpecification.builder()
.name("get_weather")
.description("查询指定城市的当前天气")
.parameters(ToolParameters.builder()
.addProperty("city", "string", "城市名", true)
.build())
.build();
ChatResponse first = model.chat(ChatRequest.builder()
.message(UserMessage.from("杭州今天天气怎么样?"))
.parameters(DefaultChatRequestParameters.builder()
.tool(weather)
.toolChoice(ToolChoice.AUTO)
.build())
.build());
// 模型返回工具调用:AiMessage.text() == null,finishReason = TOOL_EXECUTION
ToolExecutionRequest call = first.aiMessage().toolExecutionRequests().get(0);
System.out.println(call.name()); // get_weather
System.out.println(call.arguments()); // {"city":"杭州"}
// 执行工具后,把结果以 ToolExecutionResultMessage(id 关联)回填,再发起第二轮
ChatResponse second = model.chat(ChatRequest.builder()
.message(UserMessage.from("杭州今天天气怎么样?"))
.message(first.aiMessage())
.message(ToolExecutionResultMessage.from(call.id(), call.name(), "杭州今天 22℃,天气晴。"))
.build());
System.out.println(second.aiMessage().text()); // "杭州今天 22℃,天气晴。"
System.out.println(second.finishReason()); // STOP
重点:工具调用请求与工具执行结果通过
id关联;AiMessage同时容纳文本与toolExecutionRequests(), 因此「文本 + 工具调用」的混合输出也能表达。
6.1.3、流式(纯文本 + 思考过程)
以 OpenAI-compatible 推理模型为例(如 DeepSeek 系列,思考增量放在 reasoning_content):
curl(-N 关闭缓冲,持续输出 SSE):
curl -N https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-reasoner",
"stream": true,
"stream_options": { "include_usage": true },
"messages": [
{"role": "user", "content": "用一句话解释什么是 CompletableFuture。"}
]
}'
返回(data: 帧,先 reasoning_content 后 content):
data: {"id":"chatcmpl-9xYzAbC789","object":"chat.completion.chunk","created":1726200200,"model":"deepseek-reasoner","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"用户想要一句话解释","content":null},"finish_reason":null}]}
data: {"id":"chatcmpl-9xYzAbC789","object":"chat.completion.chunk","created":1726200200,"model":"deepseek-reasoner","choices":[{"index":0,"delta":{"reasoning_content":"CompletableFuture,我需要给出准确又简洁的定义。","content":null},"finish_reason":null}]}
data: {"id":"chatcmpl-9xYzAbC789","object":"chat.completion.chunk","created":1726200200,"model":"deepseek-reasoner","choices":[{"index":0,"delta":{"content":"CompletableFuture"},"finish_reason":null}]}
data: {"id":"chatcmpl-9xYzAbC789","object":"chat.completion.chunk","created":1726200200,"model":"deepseek-reasoner","choices":[{"index":0,"delta":{"content":" 是 Java 8 引入的异步编程工具。"},"finish_reason":null}]}
data: {"id":"chatcmpl-9xYzAbC789","object":"chat.completion.chunk","created":1726200200,"model":"deepseek-reasoner","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: {"id":"chatcmpl-9xYzAbC789","object":"chat.completion.chunk","created":1726200200,"model":"deepseek-reasoner","choices":[],"usage":{"prompt_tokens":14,"completion_tokens":52,"total_tokens":66}}
data: [DONE]
AgentForge 侧回调序列:
onPartialThinking("用户想要一句话解释")
onPartialThinking("CompletableFuture,我需要给出准确又简洁的定义。")
onPartialResponse("CompletableFuture")
onPartialResponse(" 是 Java 8 引入的异步编程工具。")
onCompleteResponse(ChatResponse{ text="CompletableFuture 是 Java 8 引入的异步编程工具。",
thinking="用户想要一句话解释CompletableFuture,我需要给出准确又简洁的定义。",
finishReason=STOP, tokenUsage=(14,52,66) })
对应调用:
StreamingChatModel model = OpenAiStreamingChatModel.builder()
.apiKey(System.getenv("OPENAI_API_KEY"))
.modelName("deepseek-reasoner")
.build();
model.chat("用一句话解释什么是 CompletableFuture。", new StreamingChatResponseHandler() {
@Override
public void onPartialThinking(String partialThinking) {
System.out.print("[思考] " + partialThinking); // 思考增量,可选展示
}
@Override
public void onPartialResponse(String partialResponse) {
System.out.print(partialResponse); // 正式回答,逐字输出
}
@Override
public void onCompleteResponse(ChatResponse completeResponse) {
System.out.println();
System.out.println("thinking=" + completeResponse.aiMessage().thinking());
System.out.println("done=" + completeResponse.finishReason());
}
@Override
public void onError(Throwable error) {
error.printStackTrace();
}
});
注意:
[DONE]帧只表示 SSE 结束;真正的聚合结果在最后一个带usage的 chunk 之后由onCompleteResponse给出。stream_options.include_usage=true是拿到usage的前提。
重点:
reasoning_content(部分实现用thinking)会被识别为思考增量,实时走onPartialThinking, 最终聚合进AiMessage.thinking();可见回答走onPartialResponse,两条通道互不干扰。
6.1.4、流式(Function Calling 增量聚合)
curl(stream: true + tools):
curl -N https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4o-mini",
"stream": true,
"stream_options": { "include_usage": true },
"messages": [
{"role": "user", "content": "杭州今天天气怎么样?"}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的当前天气",
"parameters": {
"type": "object",
"properties": { "city": { "type": "string", "description": "城市名" } },
"required": ["city"]
}
}
}
],
"tool_choice": "auto"
}'
返回(delta.tool_calls[] 按 index 分片到达):
data: {"id":"chatcmpl-9xYzAbC101","object":"chat.completion.chunk","created":1726200300,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_abc123","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}
data: {"id":"chatcmpl-9xYzAbC101","object":"chat.completion.chunk","created":1726200300,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":"}}]},"finish_reason":null}]}
data: {"id":"chatcmpl-9xYzAbC101","object":"chat.completion.chunk","created":1726200300,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"杭州\"}"}}]},"finish_reason":null}]}
data: {"id":"chatcmpl-9xYzAbC101","object":"chat.completion.chunk","created":1726200300,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}
data: {"id":"chatcmpl-9xYzAbC101","object":"chat.completion.chunk","created":1726200300,"model":"gpt-4o-mini","choices":[],"usage":{"prompt_tokens":62,"completion_tokens":18,"total_tokens":80}}
data: [DONE]
AgentForge 侧聚合结果:
按 index=0 逐片拼接 arguments:
id = "call_abc123"
name = "get_weather"
arguments = "{\"city\":" + "\"杭州\"}" = {"city":"杭州"}
│
▼ onCompleteResponse
ChatResponse{ text=null,
toolExecutionRequests=[ToolExecutionRequest(id=call_abc123, name=get_weather, arguments={"city":"杭州"})],
finishReason=TOOL_EXECUTION }
注意:流式下工具调用不会通过
onPartialResponse暴露;它只在onCompleteResponse的AiMessage.toolExecutionRequests()上一次性给出。多数 OpenAI 兼容端点带index,缺省时以新出现的id作为新调用起点兜底分桶。
补充:思考 + 工具调用同流。OpenAI-compatible 推理模型在带 tools 时,可能先流式输出
reasoning_content,再输出 tool_calls。两条通道先后触发,最终仍聚合为同一个 ChatResponse:
data: {...,"delta":{"reasoning_content":"用户问的是杭州天气,需要调用 get_weather。","content":null}...}
data: {...,"delta":{"reasoning_content":"参数 city=杭州。","content":null}...}
data: {...,"delta":{"tool_calls":[{"index":0,"id":"call_abc123","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"杭州\"}"}}]}...}
data: {...,"delta":{},"finish_reason":"tool_calls"}
data: [DONE]
│
▼ onCompleteResponse
ChatResponse{ thinking="用户问的是杭州天气,需要调用 get_weather。参数 city=杭州。",
toolExecutionRequests=[ToolExecutionRequest(id=call_abc123, name=get_weather, arguments={"city":"杭州"})],
finishReason=TOOL_EXECUTION }
6.2、Anthropic
6.2.1、非流式
curl:
curl https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-3-5-sonnet-latest",
"max_tokens": 256,
"system": "You are a helpful assistant.",
"messages": [
{"role": "user", "content": "用一句话解释什么是 CompletableFuture。"}
]
}'
返回(节选):
{
"id": "msg_01AbCdEfGh",
"type": "message",
"role": "assistant",
"model": "claude-3-5-sonnet-latest",
"content": [
{
"type": "text",
"text": "CompletableFuture 是 Java 8 引入的异步编程工具,用来表示一个尚未完成、但未来会给出结果的计算。"
}
],
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": { "input_tokens": 20, "output_tokens": 42 }
}
AgentForge 侧映射:
content[].type == "text" -> 拼接成 AiMessage.text()
stop_reason=end_turn -> FinishReason.STOP
usage.input_tokens -> TokenUsage.inputTokens = 20
usage.output_tokens -> TokenUsage.outputTokens = 42
input + output -> TokenUsage.totalTokens = 62
id / model / type -> ChatResponse.metadata
对应调用:
ChatModel model = AnthropicChatModel.builder()
.apiKey(System.getenv("ANTHROPIC_API_KEY"))
.modelName("claude-3-5-sonnet-latest")
.maxTokens(256)
.build();
ChatResponse response = model.chat(ChatRequest.builder()
.message(SystemMessage.from("You are a helpful assistant."))
.message(UserMessage.from("用一句话解释什么是 CompletableFuture。"))
.build());
System.out.println(response.aiMessage().text());
System.out.println(response.tokenUsage().totalTokens()); // 62
重点:
system在 OpenAI 里是messages[].role=system,在 Anthropic 里是顶层system字段, 但 AgentForge 上层始终使用同一个SystemMessage。
6.2.2、流式
curl:
curl -N https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-3-5-sonnet-latest",
"max_tokens": 256,
"stream": true,
"messages": [
{"role": "user", "content": "用一句话解释什么是 CompletableFuture。"}
]
}'
返回(event: + data: 帧):
event: message_start
data: {"type":"message_start","message":{"id":"msg_01AbCdEfGh","type":"message","role":"assistant","model":"claude-3-5-sonnet-latest","content":[],"stop_reason":null,"usage":{"input_tokens":20,"output_tokens":1}}}
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"CompletableFuture"}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" 是 Java 8 引入的异步编程工具。"}}
event: content_block_stop
data: {"type":"content_block_stop","index":0}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":24}}
event: message_stop
data: {"type":"message_stop"}
AgentForge 侧回调序列:
onPartialResponse("CompletableFuture")
onPartialResponse(" 是 Java 8 引入的异步编程工具。")
onCompleteResponse(ChatResponse{ text="CompletableFuture 是 Java 8 引入的异步编程工具。",
finishReason=STOP, tokenUsage=(20,24) })
对应调用与 6.1.2 相同,只是把模型换成 AnthropicStreamingChatModel:
StreamingChatModel model = AnthropicStreamingChatModel.builder()
.apiKey(System.getenv("ANTHROPIC_API_KEY"))
.modelName("claude-3-5-sonnet-latest")
.maxTokens(256)
.build();
model.chat("用一句话解释什么是 CompletableFuture。", handler);
弊端说明:Anthropic 流式的
usage分两处给出——message_start给input_tokens,message_delta给累计的output_tokens,Provider 需要把两处合并后再写入TokenUsage。
6.3、一键真实验证脚本
仓库中已提供两份可直接运行的脚本:
openai/verify-openai-chat.sh
anthropic/verify-anthropic-chat.sh
以 OpenAI 为例:
export OPENAI_API_KEY=sk-...
bash openai/verify-openai-chat.sh
脚本会依次执行「非流式请求 -> jq 解析 tool_calls -> 流式请求观察 data: 帧」;Anthropic 脚本同理。
真实返回同样遵循本章的映射规则。
七、验证测试
7.1、测试矩阵
| 模块 | 测试类 | 覆盖点 |
|---|---|---|
agentforge-model-api |
AiMessageToolCallTest |
单/多工具调用、文本+工具调用、thinking/attributes、值相等与不可变、UserMessage name + 多 Content、ToolExecutionRequest 字段往返 |
agentforge-model-api |
ChatModelTest / StreamingChatModelTest |
便利方法收敛、流式生命周期 |
agentforge-model-core |
DefaultChatRequestParametersTest |
默认值 / merge 覆盖 / customParameters Map 合并 |
agentforge-model-core |
ToolServiceTest / DefaultToolExecutorTest |
推理-执行循环、参数绑定、错误处理 |
agentforge-model-openai |
OpenAiFunctionCallTest |
单/多 tool_calls、混合文本、tools/tool_choice(含 SPECIFIC)序列化、tool result 回流 |
agentforge-model-openai |
OpenAiStreamingFunctionCallTest |
单工具调用 delta 合并、多工具交错、文本+工具同流、无 index 兜底 |
agentforge-model-anthropic |
AnthropicToolUseTest |
tool_use 解析、多 tool_use、tool_result 回流、input_schema 序列化、多 Content 用户消息 |
agentforge-model-anthropic |
AnthropicStreamingToolUseTest |
input_json_delta 聚合、text_delta 与多 tool_use 混合流式、metadata / token usage |
7.2、如何做到不依赖真实 API Key
问题引导:140+ 单测如何在没有真实 API Key 的情况下稳定跑通?
答案就是 HttpTransport SPI。测试时注入一个 Fake Transport,直接返回预置的 JSON / SSE 行,即可覆盖:
- 请求 payload 序列化是否正确;
- 响应解析、finish reason、token usage 映射是否正确;
- 流式聚合、工具调用合并是否正确;
- 错误路径(HTTP 4xx/5xx)是否转成
ModelException。
重点:传输层的可替换性,不只是为了支持 OkHttp,更是为了让核心协议逻辑能够被离线、确定性地单测。
八、边界、兼容与演进
8.1、已落地能力
ChatModel/StreamingChatModel统一入口;ChatRequest/ChatRequestParameters/ChatResponse统一出入参;AiMessage.toolExecutionRequests()+ToolExecutionRequest;ToolSpecification/ToolParameters(JSON-Schema 风格,纯Map)/ToolChoice;- OpenAI / Anthropic 的 tool 请求发送链路与 tool-result wire mapping;
- OpenAI 流式
tool_callsdelta 按index聚合; AnthropicStreamingChatModel(含tool_useinput_json_delta聚合);AiMessage.thinking()/attributes与StreamingChatResponseHandler.onPartialThinking(...);UserMessage的name+List<Content>多模态建模(当前提供TextContent);agentforge-model-registry工厂化装配。
8.2、待补齐能力
ImageContent/AudioContent/VideoContent/PdfFileContent等多模态Content实现与 Provider wire mapping;- 流式过程暴露
onPartialToolCall(...)增量回调(当前仅最终ChatResponse暴露完整工具调用); - Structured Output / JSON Schema 强类型参数绑定与运行时校验;
thinking的 Provider 级强类型协议(当前只保留字段位);- Prompt Cache;
- retry / backoff / rate-limit policy;
- metrics / tracing / request-id 标准化;
- 其他模型类别:
EmbeddingModel/ImageModel/AudioModel。
8.3、customParameters 的边界
customParameters 用于快速支持 Provider 新字段:
DefaultChatRequestParameters.builder()
.customParameter("reasoning_effort", "high")
.build();
弊端说明:
customParameters若被滥用,会让 Core 类型系统逐步失效,上层重新被迫感知供应商字段。 因此它只应是「临时通道」,而不是「长期方案」。
判断标准:
仅单一 Provider 使用 -> customParameters
多个 Provider 都出现且语义稳定 -> Core 强类型参数
Agent/Tool 核心流程必须理解 -> Core 强类型模型
8.4、兼容性原则
- 优先新增接口默认方法或新类型,避免破坏已有 Provider;
- Core 不暴露供应商 SDK 类型;
- Provider 特有协议优先留在 Provider module;
- request/response 对象尽量不可变;
- 公共 API 保持 Java 8 可编译,默认推荐运行在 JDK 17;
- Streaming 与 Blocking 最终必须收敛到同一
ChatResponse语义。
九、总结
回到开头那个问题:换模型时,Agent 主循环能不能一行不改?
AgentForge 的答案是:可以。做法是把「变」与「不变」彻底分开——
- 不变:
ChatModel/ChatRequest/ChatMessage/ChatResponse语义、流式生命周期、异常边界; - 变:每家厂商的 wire 协议,全部下沉到各自的 Provider 模块,并借助
HttpTransport、包私有 wire helper 与LlmFactory完成隔离与装配。
由此形成一条可复制的扩展路径:定义标准协议 → 新增 Provider 模块 → 五步协议转换 → 注册进 LlmFactory。 这套边界保证 AgentForge 从 LLM 层继续向 Tool Calling、Agent Runtime、Memory 和 Workflow 演进时,上层 不会被某一家模型协议锁死,也让「新增一个协议模型」退化为一件低风险、可复制的工作。
参考资料
[1]. LangChain4j - Build LLM-powered applications in Java
[2]. OpenAI Chat Completions API Reference
[3]. Anthropic Messages API Reference
[4]. OpenAI Streaming Responses (SSE)
[5]. Anthropic Streaming Messages
整理者:长路 创建时间:2026.9.13 更新时间:2026.10.6
评论区请在客户端页面查看