超越 JSON:将 Spring AI 工具响应格式转换为 TOON、XML、CSV、YAML 等

工程 | Christian Tzolov | 2025年11月25日 | ...

JSON 是 LLM 工具响应的首选格式,但最近关于 TOON(面向令牌的对象表示法)等替代格式的讨论声称在令牌效率和性能方面具有潜在优势。尽管争论仍在继续——批判性分析 指出 结果具有上下文依赖性——问题是:如何在你的 Spring AI 应用程序中试验这些格式?

本文演示了如何配置 Spring AI 以在 JSONTOONXMLCSVYAML 之间转换工具响应,使你能够决定哪种格式最适合你的特定用例。

Spring AI 工具调用:快速概览

让我们简要回顾一下 Spring AI 工具调用 的工作原理

  1. 工具定义(名称、描述、参数 schema)被添加到聊天请求中。
  2. 当模型决定调用工具时,它会发送工具名称和输入参数。
  3. Spring AI 使用提供的参数识别并执行工具。
  4. Spring AI 处理工具结果。
  5. Spring AI 将工具结果作为对话历史的一部分发送回模型。
  6. 模型使用工具结果作为附加上下文生成最终响应。

ToolCallback 接口是此过程的核心。每个工具都封装在一个 `ToolCallback` 中,用于处理序列化和执行逻辑。

我们可以在两个关键点拦截和转换响应格式

  • 工具结果级别:工具执行后但在 JSON 序列化之前(方法 1)
  • 响应级别:JSON 序列化后,将 JSON 转换为另一种格式(方法 2)

这两种方法都有其优点,选择取决于您的具体要求。让我们详细探讨每种方法。

方法 1:自定义 ToolCallResultConverter 配置

重要提示:仅适用于本地工具实现,例如 `@[Tool](https://docs.springjava.cn/spring-ai/reference/api/tools.html#_tool)`、`FunctionToolCallback` 和 `MethodToolCallback`。目前,MCP 工具不支持此功能。

ToolCallResultConverter 接口提供对单个工具格式的细粒度控制。DefaultToolCallResultConverter 将结果序列化为 JSON,但您可以通过提供自己的 ToolCallResultConverter 实现来自定义序列化过程。例如,自定义 ToonToolCallResultConverter 可以是这样的

public static class ToonToolCallResultConverter implements ToolCallResultConverter {

    private ToolCallResultConverter delegate = new DefaultToolCallResultConverter();
    
    @Override
    public String convert(@Nullable Object result, @Nullable Type returnType) {
        // First convert to JSON using the default converter
        String json = this.delegate.convert(result, returnType);

        // Then convert JSON to TOON
        return JToon.encodeJson(json);
    }
}

它使用默认的 JSON 转换器,然后使用 JToontoon4j 等库转换为 TOON。

使用 @Tool 注册

@Tool(description = "Get random titanic passengers", 
      resultConverter = ToonToolCallResultConverter.class) // (1)
public List<String> randomTitanicToon(
    @ToolParam(description = "Number of records to return") int count) {
    return TitanicData.getRandomTitanicPassengers(count);
}

使用 `resultConverter` 属性设置自定义 ToonToolCallResultConverter。

执行流程: 工具执行 → 默认转换器创建 JSON → TOON 转换器转换 JSON → LLM 接收 TOON 响应。

您还可以通过编程方式将 ToolCallResultConverter 注册到 FunctionToolCallbackMethodToolCallback 构建器中。

限制

  • 不兼容 MCP:不适用于 `@[McpTool](https://docs.springjava.cn/spring-ai/reference/api/tools.html#_model_context_protocol_tools)`(模型上下文协议工具)
  • 重复:必须为每个需要转换的工具实现并注册
  • 维护开销:更改需要更新多个工具定义

Application2.java 提供了一个实现示例。

方法 2:全局工具响应配置

使用自定义 `ToolCallbackProvider` 全局应用格式转换,该提供程序使用委托模式包装现有提供程序

Original ToolCallbackProvider
    ↓ wrapped by
DelegatorToolCallbackProvider
    ↓ creates wrapped callbacks
DelegatorToolCallback (for each tool)
    ↓ intercepts call() method
    ↓ converts response
JSON → Target Format (TOON/XML/CSV/YAML)

组件 1:DelegatorToolCallbackProvider

public class DelegatorToolCallbackProvider implements ToolCallbackProvider {
    private final ToolCallbackProvider delegate;
    private final ResponseConverter.Format format;
    
    public DelegatorToolCallbackProvider(ToolCallbackProvider delegate, 
                                         ResponseConverter.Format format) {
        this.delegate = delegate;
        this.format = format;
    }
    
    @Override
    public ToolCallback[] getToolCallbacks() {
        return Stream.of(this.delegate.getToolCallbacks())
            .map(callback -> new DelegatorToolCallback(callback, this.format))
            .toArray(ToolCallback[]::new);
    }
}

此提供程序包装现有的 `ToolCallbackProvider`,并为每个工具回调创建一个 `DelegatorToolCallback` 包装器。格式参数指定要转换为的格式。

组件 2:DelegatorToolCallback

public static class DelegatorToolCallback implements ToolCallback {
    private final ToolCallback delegate;
    private final ResponseConverter.Format format;
    
    public DelegatorToolCallback(ToolCallback delegate, 
                                ResponseConverter.Format format) {
        this.delegate = delegate;
        this.format = format;
    }
    
    @Override
    public ToolDefinition getToolDefinition() {
        return this.delegate.getToolDefinition();
    }
    
    @Override
    public String call(String toolInput) {
        // Call the original tool to get JSON response
        String jsonResponse = this.delegate.call(toolInput);
        // Convert to target format
        return ResponseConverter.convert(jsonResponse, this.format);
    }
}

回调包装器拦截 `call()` 方法,允许原始工具正常执行,然后将其 JSON 响应转换为所需的格式。

组件 3:ResponseConverter 实用程序

public class ResponseConverter {
    
    public enum Format {
        TOON, YAML, XML, CSV, JSON
    }
    
    public static String convert(String json, Format format) {
        switch (format) {
            case TOON: return jsonToToon(json);
            case YAML: return jsonToYaml(toJsonNode(json));
            case XML: return jsonToXml(toJsonNode(json));
            case CSV: return jsonToCsv(toJsonNode(json));
            case JSON: return json;
        }
        throw new IllegalStateException("Unsupported format: " + format);
    }
    
    private static String jsonToToon(String jsonString) {...}
    private static String jsonToYaml(JsonNode jsonNode) {...}    
    private static String jsonToXml(JsonNode jsonNode) {...}
    private static String jsonToCsv(JsonNode jsonNode) {...}
}

ResponseConverter 为每种支持的格式提供转换方法,处理每种格式的特定要求(例如为 XML 包装数组或为 CSV 构建动态 schema)。

使用示例

@SpringBootApplication
public class Application {
    
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
    
    @Bean
    CommandLineRunner commandLineRunner(ChatClient.Builder chatClientBuilder,
                                       ToolCallbackProvider toolCallbackProvider) {
        
        // Wrap the provider with format conversion
        var provider = new DelegatorToolCallbackProvider(
            toolCallbackProvider, 
            ResponseConverter.Format.TOON
        );
        
        // Configure ChatClient with the wrapped provider
        var chatClient = chatClientBuilder
            .defaultToolCallbacks(provider)
            .build();
        
        return args -> {
            var response = chatClient
                .prompt("Please show me 10 Titanic passengers?")
                .call()
                .chatResponse();
            
            System.out.println(String.format("""
                RESPONSE: %s
                USAGE: %s
                """, 
                response.getResult().getOutput().getText(), 
                response.getMetadata().getUsage()));
        };
    }
    
    @Bean
    MethodToolCallbackProvider methodToolCallbackProvider() {
        return MethodToolCallbackProvider.builder()
            .toolObjects(new MyTools())
            .build();
    }
    
    static class MyTools {
        @Tool(description = "Get titanic passengers")
        public List<String> randomTitanicToon(
            @ToolParam(description = "Number of records to return") int count) {
            return TitanicData.getTitanicPassengersInRange(30, count);
        }
    }
}

执行流程: 用户提示 → LLM 调用工具 → 包装器拦截 → 工具执行 → 创建 JSON → 格式转换器转换 → LLM 接收转换后的响应。

Application 示例利用了 ToolCallAdvisor(例如,将工具执行作为 Advisor 链的一部分)和一个自定义日志记录 Advisor `MyLogAdvisor`,它有助于查看不同格式的实际工具响应。此 Advisor 将打印出工具响应,让您看到目标格式的输出。

格式转换详情

让我们检查每种支持的格式,看看输出是什么样的。

JSON(默认)

[{"PassengerId":"31","Survived":"0","Pclass":"1","Name":"Uruchurtu, Don. Manuel E","Sex":"male","Age":40,"SibSp":"0","Parch":"0","Ticket":"PC 17601","Fare":27.7208,"Cabin":null,"Embarked":"C"},
{"PassengerId":"32","Survived":"1","Pclass":"1","Name":"Spencer, Mrs. William Augustus (Marie Eugenie)","Sex":"female","Age":null,"SibSp":"1","Parch":"0","Ticket":"PC 17569","Fare":146.5208,"Cabin":"B78","Embarked":"C"},
{"PassengerId":"33","Survived":"1","Pclass":"3","Name":"Glynn, Miss. Mary Agatha","Sex":"female","Age":null,"SibSp":"0","Parch":"0","Ticket":"335677","Fare":7.75,"Cabin":null,"Embarked":"Q"},
{"PassengerId":"34","Survived":"0","Pclass":"2","Name":"Wheadon, Mr. Edward H","Sex":"male","Age":66,"SibSp":"0","Parch":"0","Ticket":"C.A. 24579","Fare":10.5,"Cabin":null,"Embarked":"S"},
{"PassengerId":"35","Survived":"0","Pclass":"1","Name":"Meyer, Mr. Edgar Joseph","Sex":"male","Age":28,"SibSp":"1","Parch":"0","Ticket":"PC 17604","Fare":82.1708,"Cabin":null,"Embarked":"C"}]  

TOON

[5]{PassengerId,Survived,Pclass,Name,Sex,Age,SibSp,Parch,Ticket,Fare,Cabin,Embarked}:
  "31","0","1","Uruchurtu, Don. Manuel E",male,40,"0","0",PC 17601,27.7208,null,C
  "32","1","1","Spencer, Mrs. William Augustus (Marie Eugenie)",female,null,"1","0",PC 17569,146.5208,B78,C
  "33","1","3","Glynn, Miss. Mary Agatha",female,null,"0","0","335677",7.75,null,Q
  "34","0","2","Wheadon, Mr. Edward H",male,66,"0","0",C.A. 24579,10.5,null,S
  "35","0","1","Meyer, Mr. Edgar Joseph",male,28,"1","0",PC 17604,82.1708,null,C

XML


<ObjectNode>
<root><PassengerId>31</PassengerId><Survived>0</Survived><Pclass>1</Pclass><Name>Uruchurtu, Don. Manuel E</Name><Sex>male</Sex><Age>40</Age><SibSp>0</SibSp><Parch>0</Parch><Ticket>PC 17601</Ticket><Fare>27.7208</Fare><Cabin/><Embarked>C</Embarked></root>
<root><PassengerId>32</PassengerId><Survived>1</Survived><Pclass>1</Pclass><Name>Spencer, Mrs. William Augustus (Marie Eugenie)</Name><Sex>female</Sex><Age/><SibSp>1</SibSp><Parch>0</Parch><Ticket>PC 17569</Ticket><Fare>146.5208</Fare><Cabin>B78</Cabin><Embarked>C</Embarked></root>
<root><PassengerId>33</PassengerId><Survived>1</Survived><Pclass>3</Pclass><Name>Glynn, Miss. Mary Agatha</Name><Sex>female</Sex><Age/><SibSp>0</SibSp><Parch>0</Parch><Ticket>335677</Ticket><Fare>7.75</Fare><Cabin/><Embarked>Q</Embarked></root>
<root><PassengerId>34</PassengerId><Survived>0</Survived><Pclass>2</Pclass><Name>Wheadon, Mr. Edward H</Name><Sex>male</Sex><Age>66</Age><SibSp>0</SibSp><Parch>0</Parch><Ticket>C.A. 24579</Ticket><Fare>10.5</Fare><Cabin/><Embarked>S</Embarked></root>
<root><PassengerId>35</PassengerId><Survived>0</Survived><Pclass>1</Pclass><Name>Meyer, Mr. Edgar Joseph</Name><Sex>male</Sex><Age>28</Age><SibSp>1</SibSp><Parch>0</Parch><Ticket>PC 17604</Ticket><Fare>82.1708</Fare><Cabin/><Embarked>C</Embarked></root>
</ObjectNode>

YAML

---
- PassengerId: "31"
  Survived: "0"
  Pclass: "1"
  Name: "Uruchurtu, Don. Manuel E"
  Sex: "male"
  Age: 40
  SibSp: "0"
  Parch: "0"
  Ticket: "PC 17601"
  Fare: 27.7208
  Cabin: null
  Embarked: "C"
...
- PassengerId: "35"
  Survived: "0"
  Pclass: "1"
  Name: "Meyer, Mr. Edgar Joseph"
  Sex: "male"
  Age: 28
  SibSp: "1"
  Parch: "0"
  Ticket: "PC 17604"
  Fare: 82.1708
  Cabin: null
  Embarked: "C"

CSV

PassengerId,Survived,Pclass,Name,Sex,Age,SibSp,Parch,Ticket,Fare,Cabin,Embarked
31,0,1,"Uruchurtu, Don. Manuel E",male,40,0,0,"PC 17601",27.7208,,C
32,1,1,"Spencer, Mrs. William Augustus (Marie Eugenie)",female,,1,0,"PC 17569",146.5208,B78,C
33,1,3,"Glynn, Miss. Mary Agatha",female,,0,0,335677,7.75,,Q
34,0,2,"Wheadon, Mr. Edward H",male,66,0,0,"C.A. 24579",10.5,,S
35,0,1,"Meyer, Mr. Edgar Joseph",male,28,1,0,"PC 17604",82.1708,,C

Token 用量

以下是每种格式的 Token 用量估算

格式 提示 Token 完成 Token 总 Token
CSV 293 522 815
TOON 308 538 846
JSON 447 545 992
YAML 548 380 928
XML 599 572 1171

最佳实践和建议

  • 从 JSON 开始——它经过验证、安全且普遍理解
  • 在您的特定上下文中衡量性能;不要假设替代方案总是更好
  • 避免将复杂的嵌套结构转换为 CSV 或 TOON
  • 在所有转换器中包含错误处理
  • 当转换失败时提供 JSON 回退
  • 记录转换指标以进行监控

结论

Spring AI 通过两种不同的方法提供了尝试工具响应格式的灵活性。当您需要细粒度控制时,使用 `ToolCallResultConverter` 进行选择性、按工具转换。选择全局 `DelegatorToolCallbackProvider` 方法,以在所有工具(包括 MCP 工具)中实现一致的格式转换。两者都支持多种格式——TOON、YAML、XML、CSV 和 JSON——让您可以自由地针对您的特定用例进行优化。

自己尝试一下

注意:以下代码仅用于演示目的,在没有适当的测试、错误处理和安全考虑的情况下,不应在生产中使用。

完整的演示可在 GitHub 上获取。使用不同的格式运行它

./mvnw spring-boot:run -Dspring.ai.tool.response.format=TOON
./mvnw spring-boot:run -Dspring.ai.tool.response.format=CSV  
./mvnw spring-boot:run -Dspring.ai.tool.response.format=YAML

尝试不同的格式并在您的特定环境中衡量它们的影响,以确定最适合您用例的方案。


资源

获取 Spring 新闻通讯

通过 Spring 新闻通讯保持联系

订阅

领先一步

VMware 提供培训和认证,助您加速进步。

了解更多

获得支持

Tanzu Spring 提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件,只需一份简单的订阅。

了解更多

即将举行的活动

查看 Spring 社区所有即将举行的活动。

查看所有