前言
在进入正文之前,先交代一下这些文章的来龙去脉。
AgentForge是一个面向 Java 开发者、从LLM 最底层能力开始构建的开源 Agent 框架。它不从高度封装的 Agent API 起步,而是先建立稳定、统一、可扩展的模型抽象,再逐层向上锻造 Tool、Memory、Middleware、Reasoning 与 Agent Runtime 等能力。
AgentForge = Agent + Forge:Agent代表能理解目标、进行推理、调用工具并完成任务的智能体,Forge则强调把原始智能持续加工、塑形、强化,最终锻造成真正可用的产品。
本系列《AgentForge 核心模块设计原理》沿着这条自底向上的路径,逐个模块拆解它的设计原理与实现细节。本文聚焦AgentForge HTTP 工具模块的核心设计——如何把一组 REST 接口声明式映射为统一的ToolSpecification与ToolExecutor,让模型像调用本地方法一样调用 HTTP 接口。
- 开源仓库(GitHub):https://github.com/changluya/AgentForge
- 项目文档站:https://changluya.github.io/AgentForge/
- Gitee 镜像:https://gitee.com/changluJava/agent-forge
- 开源协议:MIT
gitclone https://github.com/changluya/AgentForge.gitcdAgentForge mvn cleaninstall-DskipTests如果这套「自底向上」的设计对你有帮助,欢迎到 GitHub 给 AgentForge 点一个 Star。
一、背景与问题引入
1.1、场景驱动:接入钉钉机器人时,我们卡在了哪
在为 AgentForge 做外部能力接入时,我们遇到一个很现实的场景:
团队希望 Agent 能发钉钉消息、查 CRM 客户、拉工单列表。这些能力既没有 MCP Server,也不值得为每个接口写一个 Java
@Tool方法——它们只是普通的 REST API。
由此带来三个具体痛点:
- 接口多:一个 SaaS 动辄几十个接口,逐个写
@Tool方法维护成本极高; - 模型无从下手:把"一个通用 HTTP 工具"丢给模型,它会自己编 URL、乱加参数,既不可控也不安全;
- 核心零依赖:AgentForge 核心是 Java 8 + 零第三方库,不能为接 HTTP 引入 OkHttp / Retrofit / Jackson。
1.2、问题引导:如何让模型"说人话"就能调 HTTP?
问题:如何让模型把一句自然语言,变成一次参数正确、地址可控的 HTTP 调用?
答案不是"让模型自己拼 HTTP",而是:
- 由开发者预先声明每个接口(地址、方法、参数)→ 生成
ToolSpecification; - 模型只在已声明的工具里选择、只填声明的参数;
- 运行时由执行器把参数"填"进 URL / Header / Body 并发起请求。
这正是本模块要做的事。
1.3、设计目标与约束
| 目标 | 说明 |
|---|---|
| 声明式 | 用HttpPlugin描述一组接口,不写胶水代码 |
| 零依赖 | 复用内置Json与HttpTransport(JdkHttpTransport) |
| 可控 | 模型只能调用已声明工具,URL 由插件固定 |
| 统一 | 产物是Map<ToolSpecification, ToolExecutor>,与 local / mcp 同一张工具表 |
| 可扩展 | IHttpPluginSPI + curl 互转,降低接入成本 |
二、核心概念讲解
2.1、三个核心抽象
HttpPlugin # 一个服务:baseUrl + 公共请求头 + 一组方法 └── HttpPluginMethod # 一个工具:name / description / httpMethod / uri / parameters └── HttpToolParameter # 一个参数:参数名 + 映射名 + 使用位置 + 数据类型 + 默认值 + 是否必填2.2、参数的四维模型(重点)
一个参数被四个维度描述,正是它让"声明"到"真实请求"的映射成为可能:
| 维度 | 枚举 | 作用 |
|---|---|---|
| 使用位置 | ParameterUseType:QUERY / BODY / PATH / HEADER | 决定参数最终落到 URL、请求体还是请求头 |
| 数据类型 | ParameterType:STRING / INTEGER / NUMBER / BOOLEAN / ARRAY / OBJECT | 决定 JSON Schema 类型与运行时类型转换 |
| 映射名 | mappedName | 模型参数名 ↔ 真实接口参数名(内外解耦,可改名) |
| 约束 | defaultValue/required | 缺省填充与必填校验 |
重点:模型看到的是
methodParamName(工具参数名),真正发给接口的是mappedName——这让"给模型看的名字"和"接口要的名字"可以不同。
2.3、与 Agent 工具抽象的关系
| HTTP 模块 | AgentForge 契约 |
|---|---|
HttpPluginMethod+ 参数 | ToolSpecification(parameters→ JSON Schema) |
HttpToolExecutor | ToolExecutor(execute/executeWithResult) |
HttpToolFactory.buildHttpTools | 产出Map<ToolSpecification, ToolExecutor>,交给ToolService |
2.4、为什么还要支持 curl
开发者手里最常见的"接口样本"就是一段 curl。支持curl → HttpPlugin,能把"复制粘贴一段 curl"直接变成可注入 Agent 的工具;反向HttpPlugin → curl则方便调试与文档化。
三、实现思路
3.1、三种方案对比
方案一:每个接口写一个@Tool方法
- 优点:类型安全、可复用 local 模式。
- 弊端说明:接口多则代码膨胀;改一个字段要改代码;维护成本高。
方案二:一个通用 HTTP 工具(模型传 url / method / body)
- 优点:实现最简单。
- 弊端说明:模型可任意构造 URL / 参数,不可控、不安全,且易编造接口;难以做鉴权与白名单。
方案三:声明式HttpPlugin映射(本模块采用)
- 优点:地址固定、参数受控、零依赖、可批量描述、可与 local / mcp 统一进
ToolService。 - 弊端说明:需定义插件结构;复杂鉴权(OAuth 刷新等)仍需宿主层补。
结论:采用方案三——用声明换可控,用映射换零依赖。
3.2、核心流程:List → Spec + Executor
HttpToolFactory.buildHttpTools(httpPlugin)对每个HttpPluginMethod生成一对:
HttpPluginMethod ├─ methodName / description ───────────► ToolSpecification.name / description ├─ parameters[].toJsonSchema() ────────► ToolSpecification.parameters(JSON Schema) └─ (baseUrl+uri, httpMethod, headers, 参数配置) ─► new HttpToolExecutor(...)重点:与 MCP 的
serverAlias__tool不同,HTTP 工具不加前缀——工具名直接用methodName,天然由开发者控制唯一性。
3.3、参数处理流水线(核心)
HttpToolExecutor.execute内部按固定顺序处理:
arguments(JSON) ──► argumentsAsMap │ ▼ processArguments ① 取实参 / 套默认值 / 校验必填 / 按 dataType 转换 │ ├─ buildUrlWithPathParams ② PATH 参数替换 {mappedName} ├─ addHeaderParameters ③ HEADER 参数写入请求头 ├─ shouldUseJsonBody ? │ ├─ true → BODY 参数 → JSON Body(Content-Type: application/json) │ └─ false → appendQueryParameters(QUERY 参数拼到 URL) ▼ HttpRequest(url, method, headers, body) ──► HttpTransport.execute- 必填缺失:抛
IllegalArgumentException("缺少必填参数 'x'"); - 类型转换:
convertValueToCorrectType支持 INTEGER / NUMBER / BOOLEAN / ARRAY / OBJECT / STRING,并对 JSON 字符串做容错解析。
3.4、请求体的"智能判断"
GET / DELETE → 强制用 Query(即使配了 BODY 也不发 Body) POST / PUT / PATCH → 只有当存在 BODY 参数(已传或有默认值)时才发 JSON Body注意:GET 即使配了 BODY 类型参数也不会发送请求体(由
shouldNotSendJsonBodyForGetEvenWithBodyConfig用例保证)。
3.5、重试与容错
- 仅对GET / DELETE的
connection reset做一次重试(幂等语义下安全); - 其余异常在
execute中兜底为文本"HTTP请求失败: {message}"。
弊端说明:当前
execute把错误降级为文本,isError仍为false;需要结构化错误时可在上层用ToolExecutionResult包装(对比:MCP 执行器已做isError无损映射)。
3.6、curl 解析与互转
| 方向 | 入口 | 说明 |
|---|---|---|
| curl → 插件 | CurlParser.parse+CurlToHttpPluginConverter.convert | 拆出 url / method / headers / query / body,生成参数 |
| 插件 → curl | HttpPluginToCurlConverter.convert(plugin[, methodName]) | 反向生成可复现命令;多方法插件需指定方法名 |
注意:一个插件含多个方法时,
convert(plugin)会拒绝,必须显式指定methodName。
3.7、能力矩阵:请求方法与参数格式
① 支持的 HTTP 方法(HttpPluginEnums.HttpMethod)
| 方法 | Code | 请求体 | 幂等重试 | 说明 |
|---|---|---|---|---|
| GET | 1 | 否(强制 Query) | ✅ 连接重置重试 1 次 | 查询 |
| POST | 2 | 有 BODY 参数时发 JSON | ❌ | 创建 |
| PUT | 3 | 有 BODY 参数时发 JSON | ❌ | 全量更新 |
| DELETE | 4 | 否(强制 Query) | ✅ 连接重置重试 1 次 | 删除(幂等) |
| PATCH | 5 | 有 BODY 参数时发 JSON | ❌ | 局部更新 |
重点:GET / DELETE 配置为 BODY 的参数不会进请求体,会被忽略(见
shouldNotSendJsonBodyForGetEvenWithBodyConfig)。
② 参数使用位置(ParameterUseType)
| 位置 | Code | 落点 | 生成规则 |
|---|---|---|---|
| QUERY | 1 | URL 查询串 | encode(mappedName)=encode(value) |
| BODY | 2 | JSON 请求体 | {mappedName: value}→Json.stringify |
| PATH | 3 | URL 路径占位符 | 替换{mappedName} |
| HEADER | 4 | 请求头 | mappedName: value |
③ 参数数据类型(ParameterType)
| 类型 | Code | JSON Schema | 运行时转换 |
|---|---|---|---|
| STRING | 1 | string | value.toString() |
| INTEGER | 2 | integer | Integer.parseInt/Number.intValue() |
| NUMBER | 3 | number | Double.parseDouble/Number.doubleValue() |
| BOOLEAN | 4 | boolean | "true"/"1"/"yes"→ true,否则 false |
| ARRAY | 5 | array(items: string) | List 直用;JSON 字符串解析;否则包成单元素数组 |
| OBJECT | 6 | object | Map 直用;JSON 字符串解析;否则空 Map |
④ 必填与默认值(RequiredStatus+defaultValue)
| 项 | Code | 行为 |
|---|---|---|
| REQUIRED | 1 | 实参与默认值都缺失 → 抛IllegalArgumentException("缺少必填参数 'x'") |
| NOT_REQUIRED | 0 | 缺失即不参与本次请求 |
重点:取值优先级为实参 >
defaultValue> 必填校验;methodParamName是模型侧名字,mappedName才是发给接口的名字。
四、实战代码
4.1、最小接入
HttpPluginplugin=HttpPlugin.builder().baseUrl("https://api.example.com").staticHeaders(Collections.singletonMap("Authorization","Bearer "+token)).pluginMethods(Collections.singletonList(HttpPluginMethod.builder().methodName("getWeather").methodDescription("查询城市天气").httpMethodType(HttpPluginEnums.HttpMethod.GET.getValue()).uri("/v1/weather").parameters(Collections.singletonList(HttpToolParameter.builder().methodParamName("city").methodParamDescription("城市名").mappedName("q").useTypeValue(HttpPluginEnums.ParameterUseType.QUERY.getValue()).dataTypeValue(HttpPluginEnums.ParameterType.STRING.getValue()).required(HttpPluginEnums.RequiredStatus.REQUIRED.getCode()).build())).build())).build();ToolServicetoolService=newToolService();toolService.tools(HttpToolFactory.buildHttpTools(plugin));ReActAgentagent=ReActAgent.builder().chatModel(chatModel).toolService(toolService).build();运行输出(模拟终端)
# 1) 注册后模型可见的工具(tools[] 摘要)[getWeather]查询城市天气 parameters:{city: string(required)}# 模型参数名 city → 实际接口参数名 ?q# 2) 执行 getWeather(city=杭州) 的 HTTP 往返(java.util.logging,INFO)===HTTP 请求详情===URL: https://api.example.com/v1/weather?q=%E6%9D%AD%E5%B7%9E 方法: GET 请求头:{Authorization=Bearer sk-***}QUERY 参数:{q=杭州}========================HTTP 响应详情===状态码:200耗时:143毫秒 响应体:{"city":"杭州","temp":26,"text":"晴"}=====================4.2、通过IHttpPlugin扩展
publicclassDingTalkPluginimplementsIHttpPlugin{privatePropertiesprops;@Overridepublicvoidinit(Propertiesprops){this.props=props;}@OverridepublicStringgetPluginName(){return"dingtalk";}@OverridepublicvoiddoCheckProps(){if(props.getProperty("token")==null){thrownewIllegalArgumentException("missing token");}}@OverridepublicHttpPlugingetHttpPlugin(){/* 依据 props 构建 */returnplugin;}}toolService.tools(newDingTalkPlugin().buildHttpTools());4.3、curl 一键转换
CurlParseResultcurl=newCurlParser().parse("curl -X POST 'https://api.example.com/v1/tickets' "+"-H 'Content-Type: application/json' "+"-d '{\"title\":\"bug\"}'");HttpPluginplugin=newCurlToHttpPluginConverter().convert(curl);toolService.tools(HttpToolFactory.buildHttpTools(plugin));运行输出(模拟终端)
# CurlParser.parse(...) 解析出的关键字段method=POST url=https://api.example.com/v1/tickets headers={Content-Type=application/json}data={"title":"bug"}# CurlToHttpPluginConverter.convert(...) 生成的 HttpPluginbaseUrl=https://api.example.com uri=/v1/tickets method=POST params=[title: string(BODY:title, required)]# buildHttpTools(...) → 模型可见工具(方法名由 curl 自动生成 executePOST)[executePOST](由curl生成) body:{"title":"..."}4.4、一次模型调用如何被闭环
对应单测:
HttpToolLoopTest#shouldCloseTheWeatherLoopThroughToolService——注入RecordingHttpTransport,离线完整走通"声明 → 注册 → 命中 → 组装 → 发请求 → 回灌"。
以 4.1 的getWeather为例,逐环节拆解:
① 声明与注册(build 期)
Map<ToolSpecification,ToolExecutor>tools=HttpToolFactory.buildHttpTools(weatherPlugin(),transport);// transport 可注入(测试用假实现)ToolServicetoolService=newToolService();toolService.tools(tools);// toolExecutors["getWeather"] = executorToolSpecification.name = "getWeather",parameters = { city: string(required) };HttpToolExecutor(baseUrl + "/v1/weather", GET, {Authorization=Bearer token}, {city → (QUERY, STRING, required)})。
② 模型侧 function call
{"name":"getWeather","arguments":"{\"city\":\"杭州\"}"}
arguments是JSON 字符串,不是对象。
③ 归一化为请求
ToolExecutionRequest{ id="call_1", name="getWeather", arguments="{\"city\":\"杭州\"}" }④ ToolService 定位执行器
toolExecutors.get("getWeather")→ 命中HttpToolExecutor(HTTP 工具不加前缀,名字即methodName)。
⑤ 参数处理(processArguments)
| 步骤 | 输入 | 结果 |
|---|---|---|
argumentsAsMap | "{\"city\":\"杭州\"}" | {city: 杭州} |
| 取实参 | key =city | 杭州 |
| 必填校验 | REQUIRED | 通过 |
| 类型转换 | STRING | "杭州" |
| 位置判定 | QUERY,映射名q | 待拼到 URL |
⑥ 组装HttpRequest
- PATH:无;HEADER:无(静态
Authorization已在staticHeaders); shouldUseJsonBody:GET →false(不发请求体);appendQueryParameters→?q=%E6%9D%AD%E5%B7%9E;- 最终:
GET https://api.example.com/v1/weather?q=%E6%9D%AD%E5%B7%9E,body=""。
⑦ 发起并取回响应
JdkHttpTransport.execute(request)→200,响应体{"city":"杭州","temp":26,"text":"晴"};HttpToolExecutor.execute直接返回响应体文本。
⑧ 回灌模型
响应体文本作为工具结果回灌 → 模型据此给出最终答复。
运行输出(模拟终端)
[user]查一下杭州天气[think]需要调用工具 getWeather[act]getWeather{"city":"杭州"}[http]GET https://api.example.com/v1/weather?q=%E6%9D%AD%E5%B7%9E →200{"city":"杭州","temp":26,"text":"晴"}[think]杭州当前26℃,天气晴。[final]杭州当前26℃,天气晴。五、验证测试
5.1、单元测试概览
用RecordingHttpTransport记录收到的HttpRequest,断言 URL / 请求头 / 请求体,无需真实网络。
5.2、测试清单(当前实现,全部通过)
| 用例 | 覆盖点 |
|---|---|
shouldSendGetQueryParameters | QUERY 参数拼接 |
shouldSubstitutePathParameters | PATH 占位符替换 |
shouldSendJsonBodyForPost | BODY → JSON Body |
shouldConvertArrayAndObjectBodyValues | ARRAY / OBJECT 类型转换 |
shouldParseJsonEncodedArrayString | 字符串数组容错 |
shouldAddStaticAndDynamicHeaders | 静态 + HEADER 参数 |
shouldApplyDefaultValueWhenArgumentMissing | 默认值 |
shouldThrowWhenRequiredArgumentMissing | 必填校验 |
shouldRetryOnceOnConnectionResetForGet | 幂等重试 |
shouldReturnFailureTextWhenPostFails | 失败降级文本 |
shouldNotSendJsonBodyForGetEvenWithBodyConfig | GET 不发 Body |
HttpToolLoopTest#shouldCloseTheWeatherLoopThroughToolService | 端到端闭环:插件构建 → ToolService → function call → GET 请求 → 回灌(对齐 4.4) |
HttpToolFactoryTest | Spec / Executor 构建、IHttpPlugin、null 拒绝 |
CurlParserConverterTest | curl 解析、双向转换、多方法拒绝 |
5.3、边界与风险
- 错误语义:
execute把异常降级为文本,isError=false,需要结构化错误要自行包装。 - 鉴权复杂度:静态头 / 参数够用;OAuth 刷新、签名等需宿主层实现。
- 超时:默认连接 10 分钟、读取 15 分钟(构造时可调),偏宽松,按场景收紧。
六、总结与展望
- 本质:把"一组 HTTP 接口"声明式映射成模型可调用的
ToolSpecification+ToolExecutor。 - 核心:四维参数模型(位置 × 类型 × 映射名 × 约束)+ 固定顺序的参数流水线。
- 零依赖:复用内置
Json与JdkHttpTransport,与 Java 8 / 零依赖约束一致。 - 统一:产物与其他模式一样是
Map<ToolSpecification, ToolExecutor>,可同表共存。 - 展望:结构化错误(
isError)、OpenAPI 导入、更细的超时 / 重试策略。
参考资料
[1]. MDN HTTP 请求方法
[2]. JSON Schema 官方站点
[3]. curl 命令手册
[4]. RFC 7231:HTTP/1.1 语义与方法
[5]. OpenAPI 规范
[6]. 相关内部文档:[Local 工具模块核心设计原理](…/local/Java代码转换FunctionCall协议01、AgentForge Local工具模块核心设计原理)、MCP 框架调研与 AgentForge 接入实现、ChatModel 核心协议层设计
整理者:长路 创建时间:2026.10.5 更新时间:2026.10.5