ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

让 AI 真正“看懂“业务数据:基于注解的 DTO 自然语言转译与 MCP 接入 TaoToken 实践

让 AI 真正“看懂“业务数据:基于注解的 DTO 自然语言转译与 MCP 接入 TaoToken 实践 1. 当 AI 面对 DTO 时到底卡在哪字段语义缺失与自然语言转译的刚需先说一个我最近在接口联调里反复遇到的场景。后端返回一个订单对象字段长这样orderId、operatorId、status、moneyAmount、timeEnd、stopDesc。人看一眼大概能猜但把这段 JSON 原样丢给大模型让它回答“这个订单是谁的、花了多少钱、为什么结束”模型的回答经常是含糊的甚至会把operatorId理解成“操作员”而不是“运营商”。这不是模型不够聪明而是我们喂给它的数据本身缺少语义。字段名是给程序员看的缩写枚举值是给状态机用的常量数值没有单位关联 ID 没有翻译。模型拿到的是一堆“冷数据”它只能靠字段名的字面意思去猜猜错的概率自然高。这个问题的本质是结构化对象到自然语言之间缺了一层转译。传统做法是让 AI 先读 JSON再自己去查字典、拼语义链路长、易出错、还费 token。更麻烦的是字典查询本身是一次额外的 API 调用多一次调用就多一次超时和失败的可能。所以我在自己的项目里换了个思路把转译这件事从 AI 运行时提前到开发者定义时。具体做法就是在 DTO 字段上加一个自定义注解Tips运行时一行代码把对象转成自然语言表达式比如订单ID:12345;订单状态:已结束;订单金额:99.99元。AI 拿到的不再是 JSON而是一句它直接能读懂的话。这篇文章要交付的就是这套方案的完整落地路径注解怎么定义、转译器怎么写、怎么通过 MCP 接入 TaoToken 让模型真正读到这些语义、以及联调时常见的报错怎么排。目标很明确——你在真实接口调试里能确认 AI 正确解释了业务字段而不是继续猜。适合谁看做企业智能助手、智能客服、MCP Server 的后端同学尤其是那些接口返回 DTO/BO/VO 对象、又想让 AI 准确理解业务含义的场景。不需要你懂大模型训练只要会 Spring Boot 和基本的反射就能跟下来。2. TaoToken 前置准备MCP 工具链接入的账号与 Key 配置在写转译器之前先把模型侧的通道打通。因为后面我们要验证“AI 能不能读懂转译后的自然语言”得有一个能调用的模型入口。这里用 TaoToken 作为模型接入层它提供 OpenAI 兼容的 API 和 MCP 工具链支持配置方式和主流 SDK 一致迁移成本低。第一步是拿到 API Key。访问控制台页面https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentconsole登录后在 API Keys 菜单里创建一个新 Key。建议按项目命名比如dto-tips-demo方便后面排查是哪个应用在调用。创建后 Key 只显示一次复制到安全的地方别直接写进代码提交到仓库。第二步是确认 Base URL。TaoToken 的 API 入口是https://taotoken.net/api注意这个地址不带任何查询参数直接作为 OpenAI SDK 的base_url使用。如果你用的是 Spring AI 或 spring-ai-alibaba配置项名称通常是spring.ai.openai.base-url。第三步是选模型。在模型对话页面https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentmodels可以看到当前可用的模型列表。做 DTO 语义理解这类任务建议选指令跟随能力强的模型因为我们要它严格按转译后的文本回答而不是自由发挥。把模型 ID 记下来后面配置里要用。第四步是接入文档。MCP 相关的协议细节和 SDK 用法在文档页https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentdoc建议先扫一遍 MCP Server 的注册流程因为后面我们要把自己的转译工具暴露成 MCP Tool。如果你打算长期做编码和 Agent 类任务可以看下 Coding Plan 页面https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcoding-plan它针对持续调用场景做了额度优化比按次调用更划算。Claude Code 用户还可以参考 Anthropic 接入页https://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentclaude-code-anthropic配置方式类似把 Base URL 和 Key 填进去即可。这里有个容易踩的坑很多人把 Key 配好了但 Base URL 写成了带/v1的地址结果请求 404。TaoToken 的入口就是https://taotoken.net/apiSDK 会自己拼路径不要手动加后缀。另外 Key 的权限要确认包含你要用的模型否则会返回 401 或权限错误。配置完成后先用一个最简单的请求验证通道是否通。可以用 curl 测一下curl https://taotoken.net/api/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -d { model: 你的模型ID, messages: [{role: user, content: 回复 OK}] }如果返回里有正常的choices内容说明通道没问题可以进入下一步写转译器了。如果报 401先检查 Key 是否复制完整、有没有多余空格如果报模型不存在回模型列表页确认 ID 拼写。3. 可复制配置Tips 注解定义与转译器核心代码这一节是全文的技术核心所有代码都可以直接复制到你的 Spring Boot 项目里。我按“注解定义 → 转译器 → 配置片段”的顺序来每一步都说明为什么这么写。3.1 Tips 注解定义注解的设计原则是“一个注解覆盖常见转译需求”字段不多不少。核心字段包括名称、枚举描述、单位、日期格式、额外说明、嵌套深度、名称策略、脱敏开关、隐藏值。Target(ElementType.FIELD) Retention(RetentionPolicy.RUNTIME) Documented public interface Tips { /** 属性的中文或自然语言描述名称 */ String name(); /** 枚举值解释属性名如 enumDescdesc 表示取枚举的 desc 字段 */ String enumDesc() default ; /** 单位用于数值后附加如元、kg */ String unit() default ; /** 日期格式如yyyy-MM-dd HH:mm:ss */ String dateFormat() default ; /** 额外解释说明 */ String explain() default ; /** 嵌套对象的最大展开深度默认 3 */ int maxDepth() default 3; /** 名称策略键用于将 ID 值翻译为名称 */ String nameStrategy() default ; /** 是否脱敏中间 1/3 信息用 * 替代 */ boolean desensitization() default false; /** 隐藏值匹配则不输出该字段 */ String hiddenValue() default ; }enumDesc这个字段值得单独说。很多项目的枚举类里有一个desc字段存中文描述比如ENDED(已结束)。加上enumDesc desc后转译器会反射读取枚举实例的desc属性输出“已结束”而不是“ENDED”。这样 AI 就不用去猜枚举常量的含义了。3.2 DTO 使用示例Setter Getter public class BizOrderDTO { Tips(name 订单ID) private Long orderId; Tips(name 运营商, nameStrategy OPERATOR_TITLE) private Long operatorId; Tips(name 订单状态, enumDesc desc) private OrderStatusEnum status; Tips(name 结束时间, dateFormat yyyy-MM-dd HH:mm:ss) private Date timeEnd; Tips(name 结束原因描述, explain 仅用于客户端数据展示) private String stopDesc; Tips(name 订单金额, unit 元) private BigDecimal moneyAmount; Tips(name 是否删除, hiddenValue false) private Boolean deleted; Tips(name 子订单) private SubOrderDTO subOrder; }注意deleted字段加了hiddenValue false意思是当值为 false 时不输出。业务对象里大量存在这种默认值字段对 AI 没意义还占 token过滤掉能显著精简输出。3.3 转译器核心实现转译器的入口是toTipsExpression内部按类型分派数组、集合、Map、普通对象。普通对象走反射遍历所有字段含父类只处理带Tips的字段。Slf4j Component public class ObjectToTipsManager { Autowired(required false) private NameStrategyHandler nameStrategyHandler; public String toTipsExpression(Object obj) { return toTipsExpression(obj, 0, 3); } public String toTipsExpression(Object obj, int currentDepth, int maxDepth) { if (obj null) { return ; } if (currentDepth maxDepth) { return toJsonString(obj); } if (obj.getClass().isArray()) { return formatArray(obj, currentDepth, maxDepth); } if (obj instanceof Collection) { return formatCollection((Collection?) obj, currentDepth, maxDepth); } if (obj instanceof Map) { return formatMap((Map?, ?) obj, currentDepth, maxDepth); } ListField allFields getAllFields(obj.getClass()); ListString parts new ArrayList(); for (Field field : allFields) { field.setAccessible(true); Tips tips field.getAnnotation(Tips.class); if (tips ! null) { try { Object value field.get(obj); String part processField(field, value, tips, currentDepth, maxDepth); if (!part.isEmpty()) { parts.add(part); } } catch (IllegalAccessException e) { log.warn(字段访问失败: {}, field.getName(), e); } } } return parts.isEmpty() ? toJsonString(obj) : String.join(;, parts); } }processField负责单个字段的处理顺序是先判断 hiddenValue 是否匹配匹配就跳过再做 nameStrategy 翻译然后格式化值接着脱敏最后追加 explain。private String processField(Field field, Object value, Tips tips, int currentDepth, int maxDepth) { if (tips.hiddenValue() ! null !tips.hiddenValue().isEmpty() isHiddenValue(value, tips.hiddenValue())) { return ; } Object processedValue value; if (!tips.nameStrategy().isEmpty() nameStrategyHandler ! null) { processedValue nameStrategyHandler.switchName(tips.nameStrategy(), value); } String fieldValue formatValue(processedValue, field.getType(), tips, currentDepth, maxDepth); String result tips.name() : fieldValue; if (tips.desensitization()) { result maskMiddle(result); } if (!StrUtil.isBlank(tips.explain())) { result result tips.explain(); } return result; }formatValue是类型分派的核心枚举、日期、数值、嵌套对象各走各的分支private String formatValue(Object value, Class? type, Tips tips, int currentDepth, int maxDepth) { if (value ! null type.isArray()) { return formatArray(value, currentDepth, maxDepth); } if (value instanceof Collection) { return formatCollection((Collection?) value, currentDepth, maxDepth); } if (value instanceof Map) { return formatMap((Map?, ?) value, currentDepth, maxDepth); } if (type.isEnum()) { return formatEnum(value, tips); } if (value instanceof Date) { return formatDate((Date) value, tips); } if (value instanceof Number) { return formatNumber((Number) value, tips); } if (!isPrimitiveType(type) !type.equals(String.class)) { return formatNestedObject(value, tips, currentDepth, maxDepth); } return String.valueOf(value); }枚举格式化用反射读enumDesc指定的属性private String formatEnum(Object value, Tips tips) { if (value null) { return ; } if (StrUtil.isBlank(tips.enumDesc())) { return String.valueOf(value); } try { Field descField value.getClass().getDeclaredField(tips.enumDesc()); descField.setAccessible(true); Object desc descField.get(value); return desc null ? String.valueOf(value) : String.valueOf(desc); } catch (NoSuchFieldException | IllegalAccessException e) { log.warn(枚举描述字段读取失败: {}, tips.enumDesc(), e); return String.valueOf(value); } }数值格式化负责拼单位日期格式化负责按dateFormat输出private String formatNumber(Number value, Tips tips) { if (value null) { return ; } String num value.toString(); return StrUtil.isBlank(tips.unit()) ? num : num tips.unit(); } private String formatDate(Date value, Tips tips) { if (value null) { return ; } String pattern StrUtil.isBlank(tips.dateFormat()) ? yyyy-MM-dd HH:mm:ss : tips.dateFormat(); return new SimpleDateFormat(pattern).format(value); }集合和数组的处理要支持递归因为列表里每个元素都可能是带注解的对象private String formatCollection(Collection? collection, int currentDepth, int maxDepth) { if (collection.isEmpty()) { return []; } ListString elements new ArrayList(); for (Object item : collection) { String itemStr toTipsExpression(item, currentDepth 1, maxDepth); if (!itemStr.isEmpty()) { elements.add(itemStr); } } return elements.isEmpty() ? [] : [ String.join(,, elements) ]; }嵌套对象递归时深度取注解maxDepth和全局深度的较小值防止无限展开private String formatNestedObject(Object obj, Tips tips, int currentDepth, int maxDepth) { int newDepth currentDepth 1; int fieldMaxDepth tips.maxDepth() 0 ? tips.maxDepth() : maxDepth; String nested toTipsExpression(obj, newDepth, Math.min(fieldMaxDepth, maxDepth)); return nested.isEmpty() ? toJsonString(obj) : { nested }; }脱敏和隐藏值判断是两个细节但很实用的方法public static String maskMiddle(String input) { if (input null || input.length() 1) return input; int len input.length(); if (len 2) return input.charAt(0) ***; int keepEachSide Math.min(len / 3, 3); return input.substring(0, keepEachSide) *** input.substring(len - keepEachSide); } private boolean isHiddenValue(Object value, String hiddenValue) { if (value instanceof Number) { BigDecimal decimalValue new BigDecimal(String.valueOf(value)); BigDecimal decimalHidden new BigDecimal(hiddenValue); return decimalValue.compareTo(decimalHidden) 0; } if (value instanceof Boolean) { Boolean boolVal (Boolean) value; if (true.equalsIgnoreCase(hiddenValue) || 1.equals(hiddenValue)) { return Boolean.TRUE.equals(boolVal); } if (false.equalsIgnoreCase(hiddenValue) || 0.equals(hiddenValue)) { return Boolean.FALSE.equals(boolVal); } return String.valueOf(value).equals(hiddenValue); } return String.valueOf(value).equals(hiddenValue); }数值比较这里用BigDecimal.compareTo而不是equals是为了解决0和0.00精度不同但语义相同的问题。这个坑我在实际项目里踩过status 0和hiddenValue 0.00用 equals 判断会漏掉。3.4 MCP 接入配置片段转译器写好了接下来把它暴露成 MCP Tool。在 Spring AI 的配置里MCP Server 的注册信息写在application.ymlspring: ai: openai: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} chat: options: model: 你的模型ID temperature: 0.2 mcp: server: name: dto-tips-server version: 1.0.0 protocol: streamabletemperature设成 0.2 是为了让模型在解释业务字段时更稳定减少自由发挥。MCP Server 的协议用streamable这是当前主流的传输方式。如果你用的是 Cline 或 Claude Code 这类客户端MCP 配置通常是一个 JSON 文件路径和原文一致{ mcpServers: { dto-tips-server: { command: java, args: [-jar, /path/to/dto-tips-server.jar], env: { TAOTOKEN_API_KEY: 你的Key, TAOTOKEN_BASE_URL: https://taotoken.net/api, TAOTOKEN_MODEL: 你的模型ID } } } }这里三件套必须齐全Base URL、Key、Model ID。少任何一个MCP 客户端启动时都会报错。我见过有人只填了 Key 没填 Model结果客户端默认用了别的模型行为不一致还找不到原因。Codex 用户如果用auth.json管理凭据格式类似{ api_key: 你的Key, base_url: https://taotoken.net/api, model: 你的模型ID }配置完成后MCP Tool 的注册代码只需要一行替换ToolMapping(name getOrderDetail, title 查询用户订单信息) public String getOrderDetail(Param(description 订单号) String orderSeq) { BizOrderDTO order orderHelper.queryOrder(orderSeq); return objectToTipsManager.toTipsExpression(order); }原来返回JSON.toJSONString(order)现在返回转译后的自然语言。AI 拿到的就是订单ID:12345;订单状态:已结束;订单金额:99.99元这样的文本理解成本大幅降低。4. 验证请求与成功结果确认 AI 正确解释业务字段配置写完了最关键的一步是验证。不能只看代码跑通要确认 AI 真的读懂了字段含义。我一般分三层验证单元测试、MCP Tool 调用、模型问答。4.1 单元测试验证转译输出先写一个最简单的测试确认转译器输出符合预期Test public void testToTipsExpression() { BizOrderDTO order new BizOrderDTO(); order.setOrderId(12345L); order.setOperatorId(10086L); order.setStatus(OrderStatusEnum.ENDED); order.setMoneyAmount(new BigDecimal(99.99)); order.setTimeEnd(new Date()); order.setStopDesc(用户主动结束); order.setDeleted(false); String result objectToTipsManager.toTipsExpression(order); System.out.println(result); assertTrue(result.contains(订单ID:12345)); assertTrue(result.contains(订单状态:已结束)); assertTrue(result.contains(订单金额:99.99元)); assertFalse(result.contains(是否删除)); }预期输出类似订单ID:12345;运营商:XX充电;订单状态:已结束;结束时间:2026-04-01 12:00:00;结束原因描述:用户主动结束 仅用于客户端数据展示;订单金额:99.99元注意是否删除没有出现因为hiddenValue false生效了。运营商显示的是名称而不是 ID说明nameStrategy翻译成功。4.2 MCP Tool 调用验证启动 MCP Server 后用客户端调用getOrderDetail工具。在 Cline 或 Claude Code 里工具调用会返回转译后的文本。你可以直接问模型“这个订单的金额是多少状态是什么”如果模型回答“金额 99.99 元状态已结束”说明转译生效。这里有个细节MCP Tool 的返回类型是 String模型拿到的是纯文本。如果你返回的是 JSON模型需要自己解析返回转译文本模型直接读。这就是为什么转译要放在 Tool 内部做而不是让模型做。4.3 模型问答验证语义理解最直接的验证是构造一个对比实验。同一份订单数据一份用原始 JSON一份用转译文本分别问模型同样的问题问题这个订单是谁的花了多少钱为什么结束原始 JSON 的回答经常是“订单 ID 为 12345operatorId 为 10086status 为 ENDEDmoneyAmount 为 99.99”它只是复述字段没有解释语义。转译文本的回答则是“这个订单属于 XX 充电金额 99.99 元状态已结束结束原因是用户主动结束”这才是我们想要的。我用 TaoToken 的模型对话页面https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentchat做过这个对比转译后的文本让模型的回答准确率明显提升尤其是枚举和单位这两类信息模型不再猜错。4.4 列表和嵌套场景验证列表场景的转译输出[{订单ID:1001;订单状态:已结束;订单金额:50.00元},{订单ID:1002;订单状态:进行中;订单金额:99.99元}]嵌套场景订单ID:1;子订单:{子订单ID:2;子订单名称:测试;子订单金额:50.00元}这两种场景下模型都能正确识别每个元素的字段含义不会因为嵌套层级深而遗漏关键信息。嵌套深度默认 3 层超过就退化成 JSON这是为了防止 token 膨胀。验证通过的标准很简单模型能准确回答业务问题而不是复述字段名。如果它还在说“operatorId 是 10086”说明转译没生效回去检查注解是否加在字段上、Retention是否是 RUNTIME。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth联调过程中我遇到过几类典型报错这里按现象、原因、解决方式逐一列出来你对照着排查。5.1 401 Unauthorized现象请求 TaoToken API 返回 401提示认证失败。原因通常有三个Key 没填、Key 填错、Key 权限不足。先检查环境变量TAOTOKEN_API_KEY是否真的注入到运行环境里很多人本地配了但容器里没配。再检查 Key 有没有多余空格或换行复制时容易带上。最后确认 Key 的权限包含你要用的模型有些 Key 是限定模型的。排查命令echo $TAOTOKEN_API_KEY | wc -c如果长度明显不对说明变量没设好。正常 Key 长度在几十个字符。5.2 local proxy failed现象MCP 客户端启动时报local proxy failed或连接被拒绝。这个报错通常和网络配置有关。先确认 MCP Server 的进程是否真的起来了端口是否被占用。如果是本地 stdio 模式检查command和args路径是否正确jar 包是否存在。如果是 streamable 模式检查端口是否被防火墙拦截。我遇到过一次是 jar 包路径写成了相对路径客户端工作目录不同导致找不到文件。改成绝对路径就好了。5.3 reading choices 报错现象调用模型返回的响应里没有choices字段或者解析时报reading choices失败。原因一般是 Base URL 配错了。比如写成了https://taotoken.net/api/v1SDK 又拼了一次/v1路径变成/api/v1/v1/chat/completions返回 404 而不是正常的 choices 结构。正确写法就是https://taotoken.net/api不要加后缀。另一个可能是模型 ID 写错返回了错误结构。回模型列表页确认 ID 拼写。5.4 OAuth 相关报错现象Claude Code 或某些客户端提示 OAuth 认证失败。这类客户端有时会走 OAuth 流程而不是 API Key。如果你用的是 API Key 模式需要在配置里明确指定认证方式避免客户端尝试 OAuth。检查配置文件里是否有auth_type之类的字段设成api_key。如果客户端强制走 OAuth参考 Anthropic 接入页https://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentclaude-code-anthropic的配置说明按它的方式填 Base URL 和 Key。5.5 转译输出为空或字段缺失现象转译结果为空字符串或者某些字段没输出。先检查字段是否有Tips注解没有注解的字段会被忽略。再检查hiddenValue是否误匹配比如status 0而hiddenValue 0字段就被过滤了。还要确认Retention是 RUNTIME如果是 CLASS 或 SOURCE反射读不到注解。嵌套对象输出为空通常是深度超限退化成 JSON 了检查maxDepth设置。5.6 枚举描述读取失败现象枚举字段输出的是ENDED而不是已结束。检查enumDesc指定的属性名是否和枚举类里的字段名一致。比如枚举类里字段叫description注解写enumDesc desc就会读不到。另外确认该字段有 getter 或者可以反射访问私有字段要setAccessible(true)。5.7 三件套缺失导致的启动失败如果你用 CC Switch、Cline MCP 或 Codex auth.json启动失败最常见的原因是三件套不全。Base URL、Key、Model ID 必须都填。我见过只填 Key 的客户端用了默认模型行为不一致也见过只填 Base URL 的认证直接失败。对照检查配置项正确值常见错误Base URLhttps://taotoken.net/api多写/v1后缀API Key控制台创建的完整 Key带空格或换行Model ID模型列表页确认的 ID拼写错误或用了不存在的模型排查时建议先用 curl 单独测 API 通道确认通道通了再测 MCP 客户端。这样能把问题范围缩小到网络层还是配置层。6. 语义一致 CTA把转译能力接到你的真实业务里走到这里你已经有了完整的注解定义、转译器实现、MCP 接入配置和排错清单。接下来就是把它接到你自己的业务 DTO 上。我的建议是先挑一个字段语义最模糊的接口试比如订单、工单、账单这类枚举和单位多的对象改造成本低但效果明显。接入路径按你的场景选如果你还在排障和接入阶段先去 API Keys 页面https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentapi-keys确认 Key 配置再对照接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentdoc检查 MCP 注册流程。如果你想先验证模型对转译文本的理解效果去模型对话页面https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentchat手动贴一段转译结果问它业务问题看回答是否准确。如果你是长期做编码和 Agent 任务Coding Plan 页面https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcoding-plan有额度优化方案适合持续调用场景。最后分享一个我在实际项目里的小技巧转译器的nameStrategy不要硬编码翻译逻辑而是抽成一个 Handler 接口不同业务注册不同的策略。这样订单的运营商翻译和账单的运营商翻译可以复用同一套逻辑改一处全生效。另外hiddenValue建议按业务对象统一约定比如所有布尔字段默认隐藏 false所有状态码默认隐藏 0这样不用每个字段单独配减少遗漏。转译这件事的价值不在于技术多复杂而在于它把“让 AI 理解业务”的成本从运行时前移到了定义时。你加一行注解AI 就少猜一次。接口调试时模型能准确说出“这个订单属于 XX 充电金额 99.99 元已结束”而不是复述字段名这就是我们要的结果。
返回列表