让 AI 真正"看懂"业务数据:基于 @Tips 注解的对象自然语言转译方案

摘要: 在企业级智能助手、智能客服、MCP Server 等 Agent 场景中,后端接口返回的 DTO/BO/VO 对象(或 JSON)对大语言模型而言是"冷数据"——字段名与技术语义无法直接映射到业务含义。这里就当前AI读取接口返回"属性名-属性值"形式的结构化对象数据可能存在理解偏差的问题,实现一种基于自定义 @Tips 注解的轻量级转译方案,将结构化对象一键转换为自然语言表达式(如 订单ID:12345;订单金额:99.99元;订单状态:已结束),无需额外的字典查询步骤,无需AI二次转义,即可显著提升 AI 的语义识别准确率与响应可靠性。


一、问题背景:AI 为什么"看不懂"我们的 DTO

在企业智能体系统中,MCP Tool 或内部 CLI 调用的返回数据通常长这样:

{
  "orderId": 1937384137282740225,
  "operatorId": 10086,
  "status": "ENDED",
  "moneyAmount": 99.99,
  "timeEnd": "2026-04-01 12:00:00",
  "stopDesc": "用户主动结束"
}

直接把这段 JSON 塞给 AI,会产生几个问题:

  1. 字段名 ≠ 业务语义 —— AI 不知道 operatorId 是"运营商ID"还是"操作员ID"
  2. 枚举值丢失可读性 —— status: "ENDED" 对 AI 来说只是一个字符串,不是"已结束"
  3. 数值缺少单位 —— 99.99 是元?是度电?AI 无法判断
  4. 关联 ID 无法翻译 —— 即使额外传入数据字典,多一步 API 调用就多一分延迟和失败风险
  5. 嵌套对象膨胀 —— 深层嵌套的 JSON 会消耗大量 token,且 AI 容易遗漏关键字段

传统的做法是:先返回 JSON → 再查字典翻译字段 → 拼成自然语言给 AI。链路长、易出错。

能不能在数据返回的同一时刻,直接生成 AI 可读的自然语言?

这就是 @Tips 注解方案的出发点。


二、核心思路:注解驱动的一次性转译

设计目标很简单:

开发者在 DTO 字段上加一行注解,运行时一行代码调用,即可得到 AI 友好的自然语言描述。

2.1 @Tips 注解定义

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Tips {

    /** 属性的中文或自然语言描述名称 */
    String name();

    /** 枚举值解释属性名,如 enumDesc="desc" 表示取枚举的 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 值翻译为名称(如运营商ID→运营商名称) */
    String nameStrategy() default "";

    /** 是否脱敏(中间 1/3 信息用 * 替代) */
    boolean desensitization() default false;

    /** 隐藏值,匹配则不输出该字段 */
    String hiddenValue() default "";
}

注解设计遵循"一个注解覆盖所有常见转译需求"的原则,字段各司其职,不多不少。

2.2 使用示例

@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 = "子订单")
    private SubOrderDTO subOrder;
}

调用方式只有一行:

String result = objectToTipsManager.toTipsExpression(order);

输出结果:

订单ID:12345;运营商:XX充电;订单状态:已结束;结束时间:2026-04-01 12:00:00;
结束原因描述:用户主动结束 仅用于客户端数据展示;订单金额:99.99元;
子订单:{子订单ID:2;子订单名称:测试子订单;子订单金额:50.00元}

三、实现架构

3.1 核心转换流程

toTipsExpression(obj)
  ├── obj 是数组    → formatArray() → 转为 [元素1,元素2,...]
  ├── obj 是集合     → formatCollection() → 转为 [元素1,元素2,...]
  ├── obj 是 Map     → formatMap() → 转为 {key1:value1,key2:value2}
  └── obj 是普通对象
       ├── 遍历所有字段(含父类)
       │    ├── 有 @Tips → processField()
       │    │    ├── hiddenValue 匹配 → 跳过
       │    │    ├── nameStrategy → 调 NameStrategyHandler 翻译
       │    │    ├── formatValue() → 按类型格式化
       │    │    │    ├── 枚举 → formatEnum()
       │    │    │    ├── Date → formatDate()
       │    │    │    ├── Number → formatNumber() + unit
       │    │    │    ├── 数组/集合/Map → 递归
       │    │    │    └── 嵌套对象 → formatNestedObject() 递归
       │    │    ├── desensitization → maskMiddle()
       │    │    └── explain → 追加说明文本
       │    └── 无 @Tips → 忽略
       └── 用 ";" 拼接所有字段 → "字段1:值1;字段2:值2"

3.2 核心代码

@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);
        }

        // 集合/数组/Map 入口
        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);
        }

        // 普通对象:反射遍历 @Tips 字段
        List<Field> allFields = getAllFields(obj.getClass());
        List<String> parts = new ArrayList<>();
        for (Field field : allFields) {
            field.setAccessible(true);
            Tips tips = field.getAnnotation(Tips.class);
            if (tips != null) {
                Object value = field.get(obj);
                String part = processField(field, value, tips, currentDepth, maxDepth);
                if (!part.isEmpty()) {
                    parts.add(part);
                }
            }
        }
        return parts.isEmpty() ? toJsonString(obj) : String.join(";", parts);
    }

    private String processField(Field field, Object value, Tips tips,
                                int currentDepth, int maxDepth) {
        // hiddenValue 匹配则跳过
        if (tips.hiddenValue() != null && !tips.hiddenValue().isEmpty()
                && isHiddenValue(value, tips.hiddenValue())) {
            return "";
        }
        // nameStrategy 翻译 ID 为名称
        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 / formatEnum / formatDate / formatNumber 等见下文
}

3.3 集合/数组/Map 支持

对于 AI 场景中最常见的列表返回(如 List<BizOrderDTO>),需要特殊处理:

/** 格式化 Collection(List、Set 等) */
private String formatCollection(Collection<?> collection, int currentDepth, int maxDepth) {
    if (collection.isEmpty()) {
        return "[]";
    }
    List<String> 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) + "]";
}

/** 格式化数组 */
private String formatArray(Object array, int currentDepth, int maxDepth) {
    List<?> list;
    if (array instanceof Object[]) {
        list = Arrays.asList((Object[]) array);
    } else if (array instanceof int[]) {
        list = Arrays.asList(((int[]) array));
    } // ... 其他基本类型数组略
    else {
        return toJsonString(array);
    }
    return formatCollection(list, currentDepth, maxDepth);
}

/** 格式化 Map */
private String formatMap(Map<?, ?> map, int currentDepth, int maxDepth) {
    if (map.isEmpty()) {
        return "{}";
    }
    Map<Object, String> converted = new LinkedHashMap<>();
    for (Map.Entry<?, ?> entry : map.entrySet()) {
        String valueStr = toTipsExpression(entry.getValue(), currentDepth + 1, maxDepth);
        converted.put(entry.getKey(), valueStr.isEmpty() ? String.valueOf(entry.getValue()) : valueStr);
    }
    List<String> parts = new ArrayList<>();
    for (Map.Entry<Object, String> entry : converted.entrySet()) {
        parts.add(entry.getKey() + ":" + entry.getValue());
    }
    return "{" + String.join(",", parts) + "}";
}

3.4 值格式化与嵌套对象

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);
}

/** 嵌套对象递归 */
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 + "}";
}

3.5 脱敏处理

对于手机号、身份证等敏感字段,注解支持一键脱敏:

@Tips(name = "联系电话", desensitization = true)
private String phone;
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);
}
// "13800138000" → "138***8000"

3.6 hiddenValue:智能过滤零值/默认值

业务对象中大量存在 status = 0deleted = false 等默认值字段,对 AI 无意义且浪费 token:

@Tips(name = "是否删除", hiddenValue = "false")
private Boolean deleted;

@Tips(name = "状态码", hiddenValue = "0")
private Integer status;
private boolean isHiddenValue(Object value, String hiddenValue) {
    if (value instanceof Number) {
        // BigDecimal 数值比较,解决 "0" vs "0.00" 精度问题
        BigDecimal decimalValue = new BigDecimal(String.valueOf(value));
        BigDecimal decimalHidden = new BigDecimal(hiddenValue);
        return decimalValue.compareTo(decimalHidden) == 0;
    }
    if (value instanceof Boolean) {
        // 支持 "true"/"false" 或 "0"/"1" 匹配
        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);
}

四、实际效果对比

4.1 单对象场景

维度 传统 JSON 返回 @Tips 转译
原始数据 {"orderId":12345,"status":"ENDED","moneyAmount":99.99} 订单ID:12345;订单状态:已结束;订单金额:99.99元
AI 理解成本 需猜测字段含义、枚举值含义 直接可读
单位信息 丢失 保留(“元”)
枚举可读性 不可读(“ENDED”) 可读(“已结束”)
Token 消耗 中等 相近或更少(去除了无意义字段)

4.2 列表场景

// 传统 JSON —— AI 需要在冗长的 JSON 中解析
[{"orderId":1001,"status":"ENDED","moneyAmount":50.00},
 {"orderId":1002,"status":"IN_PROGRESS","moneyAmount":99.99}]

// @Tips 转译 —— 一目了然
[{订单ID:1001;订单状态:已结束;订单金额:50.00元},
 {订单ID:1002;订单状态:进行中;订单金额:99.99元}]

4.3 嵌套场景

// 传统 JSON —— 深层嵌套容易丢失关键信息
{"orderId":1,"subOrder":{"subId":2,"subName":"测试","subAmount":50.00}}

// @Tips 转译 —— 结构化自然语言
订单ID:1;子订单:{子订单ID:2;子订单名称:测试;子订单金额:50.00元}

4.4 MCP Server 中的集成

在 MCP Tool 接口中,只需一行替换即可生效:

@ToolMapping(name = "getOrderDetail", title = "查询用户订单信息")
public String getOrderDetail(@Param(description = "订单号") String orderSeq) {
    BizOrderDTO order = orderHelper.queryOrder(orderSeq);
    // 原来:return JSON.toJSONString(order);
    // 现在:
    return objectToTipsManager.toTipsExpression(order);
}

@ToolMapping(name = "getOrderList", title = "查询订单列表")
public String getOrderList(@Param(description = "用户ID") Long userId) {
    List<BizOrderDTO> orders = orderHelper.queryOrders(userId);
    // 直接支持 List 转换
    return objectToTipsManager.toTipsExpression(orders);
}

五、为什么比"先返回 JSON + 后查字典"更好

对比维度 JSON + 字典方案 @Tips 转译方案
调用链路 AI 收到 JSON → 识别需要翻译的字段 → 调用字典 API → 拼接自然语言 一次调用直接得到自然语言
延迟 至少多一次字典查询 RT 无额外 RT,纯内存反射转换
可靠性 字典查询可能失败、超时、数据不一致 确定性输出,不依赖外部服务
可控性 字典数据可能被意外修改 注解即文档,改动需发版
Token 效率 原始 JSON + 字典数据双重 token 消耗 仅输出有效信息,token 更精简
开发成本 需要维护字典映射关系、处理异常分支 加注解即可,零额外代码

核心优势用一句话总结:

把"翻译"的工作从 AI 运行时转移到了开发者定义时,用确定的注解代替不确定的运行时查询。


六、适用场景与最佳实践

6.1 适用场景

  • MCP Server 的 Tool 返回值转译
  • 智能客服/助手对话中的数据展示
  • RAG 系统中结构化数据向量化前的预处理
  • 任何需要将 DTO/BO/VO 转为自然语言的 AI 交互场景

6.2 最佳实践

  1. 核心字段优先 —— 不要给所有字段加注解,只标注 AI 需要理解的关键业务字段
  2. 合理控制嵌套深度 —— 默认 3 层足够,过深会膨胀输出且 AI 难以理解
  3. 善用 hiddenValue —— 过滤掉 deleted=falseversion=0 等对 AI 无意义的默认值
  4. nameStrategy 解耦翻译逻辑 —— ID 翻译走统一的 Handler,避免硬编码
  5. 脱敏字段及时开启 —— 手机号、身份证号等敏感数据开启 desensitization = true

七、总结

@Tips 注解方案的核心价值在于用最小的开发成本,把后端数据变成 AI 真正能"理解"的自然语言。它不依赖任何 AI 框架,不增加调用链路,只是一个纯反射的字符串转换工具,但在企业智能体场景中能显著降低 AI 的"理解偏差"。


开源工程 base-ai-assistant
基于spring-boot、spring-ai、spring-ai-alibaba实现的RAG、MCP、Agent智能体基础服务框架应用;智能客服、智能运维、智能助手、简单工作流/垂直领域智能体的基础应用架构版本,按需拓展。

https://github.com/endcy/base-ai-assistant

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐