大家都在 Prompt 里写"不要编造数据",但很少有人想过——是不是你喂给大模型的数据本身就在"制造"幻觉?


一个真实的翻车现场

我们在做一个 ToB 场景的 AI Agent,对接了第三方低代码平台的数据。用户问"帮我查一下文印登记",Agent 调了工具、拿到了真实数据,结果最终给用户的表格里——多了两列、少了三行、还把张三的申请记录挪到了李四名下。

工具调了,数据是真的,但最终回复是假的

这不是 Prompt 写得不好,也不是模型太蠢。我们花了两个月时间排查,发现一个被严重低估的根因:

后端接口返回的数据太脏了。


大模型眼中的"脏数据"长什么样

我们对接的是一个低代码平台的开放 API。它的返回长这样:

{
  "_id": "6a0eb6fded4cf07f23986ab1",
  "rowid": "b1797996-4a8b-4e3f-9f2c-8d1e5a7c3b2f",
  "ctime": "2026-05-21 15:40:45",
  "caid": {
    "accountId": "a3f2b1c4-...",
    "fullname": "API",
    "avatar": "https://pic.example.com/avatar/xxx.png",
    "status": 1
  },
  "uaid": {
    "accountId": "d7e8f9a0-...",
    "fullname": "某学校管理员",
    "avatar": "https://pic.example.com/avatar/yyy.png",
    "status": 1
  },
  "ownerid": {
    "accountId": "d7e8f9a0-...",
    "fullname": "某学校管理员",
    "avatar": "https://pic.example.com/avatar/yyy.png",
    "status": 1
  },
  "66c976b1054b1ec6bac5803c": "学校荣誉",
  "66c8aa24054b1ec6bac57c96": "[{\"file_id\":\"6832a5c0c80...\",\"original_filename\":\"附件1.pdf\",\"file_size\":2048576,\"thumbnail_path\":\"https://cdn.example.com/...\",\"share_url\":\"https://...\",\"ext\":\".pdf\",\"previewUrl\":\"https://...\"}]",
  "66c8ad18054b1ec6bac57ca5": "[{\"accountId\":\"d7e8f9a0-...\",\"fullname\":\"管理员\",\"avatar\":\"https://pic.example.com/avatar/yyy.png\",\"status\":1}]",
  "66d32da7054b1ec6bac70544": null,
  "66d32da8054b1ec6bac70545": ""
}

你看到了什么?

一条业务记录,2500 个字符,有效信息大概 200 个字符。

剩下的 2300 个字符是什么?

  • _idrowidcaiduaidownerid——系统字段,用户完全不关心
  • 66c976b1054b1ec6bac5803c——这是字段 ID,不是字段名
  • avatarstatusaccountId——成员字段的冗余属性,用户只需要名字
  • file_idthumbnail_pathshare_urlpreviewUrl——附件的元数据,一个附件就能膨胀 40% 的响应体积
  • null""——空值字段,纯噪音

这些噪音,占了响应体积的 90%。


噪音是怎么"制造"幻觉的

你可能会想:大模型这么聪明,它应该能忽略噪音,只看有用的数据吧?

恰恰相反。 噪音越多,大模型越容易出错,原因有三:
在这里插入图片描述

1. 注意力被分散

大模型的注意力机制是有限的。当 JSON 里塞满了 UUID、头像 URL、缩略图路径,模型需要在大量无关信息中"找"到真正的业务数据。这个过程中,它可能:

  • caid.fullname(创建人)错认为业务字段里的人名
  • ownerid.fullname 和某个业务人员字段搞混
  • 在附件元数据的干扰下,丢失对相邻字段的注意

2. 字段 ID 无语义,逼着模型"猜"

当 JSON 的 key 是 66c976b1054b1ec6bac5803c 而不是 "荣誉类别" 时,大模型没有任何语义线索来理解这个字段。它只能靠值的内容去"推测"字段含义——而推测就有猜错的可能。

3. 信噪比太低,模型倾向"创造性补全"

这是最致命的:当真实数据只占 10%,大模型会倾向于用自己的"知识"填补信息空白。它觉得"这些数据看起来不完整,我来帮你补全一下"——然后就开始编造。

一句话总结:你给大模型的数据越脏,它说谎的概率越高。


我们怎么做的:8 阶响应瘦身

我们在后端中转层实现了一个 ResponseSlimmer 组件,对每条查询结果做 8 步精简处理:

第 1 阶:砍掉系统字段

移除:_id, rowid, caid, uaid, ownerid, autoid

这些是低代码平台内部的系统字段,用户不需要看,大模型也不该看。尤其是 caid(创建人)和 uaid(修改人)——它们的 fullname 很容易被大模型误认为业务数据中的人名字段。

第 2 阶:翻译时间字段

ctime → "创建时间"
utime → "更新时间"

保留时间信息但改成中文名。ctime 这种缩写对大模型来说完全没有语义。

第 3 阶:删除 null 值

// 优化前
{"获奖备注": null, "附加说明": null, "审批意见": null}

// 优化后
// (直接不返回这三个字段)

null 值对大模型来说是"这个字段存在但没有值"。问题是,大模型可能会尝试"帮你补上"这个值——编造一个看起来合理的内容。

删掉 null,大模型就不知道这个字段存在,也就无从编造。

第 4 阶:删除空字符串

同理,"" 也是噪音。删掉。

第 5 阶:移除排除字段

通过规则引擎配置的排除规则,把已废弃或不应暴露的字段从响应中彻底移除。比如旧版用过但已停用的字段,如果还出现在响应里,大模型可能会把它和新字段搞混。

第 6 阶:移除附件字段的元数据

附件类型的字段是响应体积膨胀的重灾区:

// 优化前:单个附件字段
"[{\"file_id\":\"6832a5c0c80\",\"original_filename\":\"附件1.pdf\",\"file_size\":2048576,\"thumbnail_path\":\"https://cdn.example.com/...\",\"share_url\":\"https://...\",\"ext\":\".pdf\",\"previewUrl\":\"https://...\",\"download_url\":\"https://...\"}]"

// 优化后:直接移除整个附件字段
// (附件的 URL、缩略图、分享链接对 LLM 生成文本回复没有帮助)

一个附件字段的元数据就能占 400+ 字符,如果一条记录有 3 个附件字段,光附件就吃掉 1200 字符。而这些信息对"生成文本回复"来说完全无用。

第 7 阶:成员字段精简

成员/人员类型的字段,低代码平台返回的是完整的用户对象数组:

// 优化前
"申报人": "[{\"accountId\":\"d7e8f9a0-...\",\"fullname\":\"管理员\",\"avatar\":\"https://pic.example.com/avatar/yyy.png\",\"status\":1}]"

// 优化后(单人)
"申报人": "管理员"

// 优化后(多人)
"参与人": "张三, 李四, 王五"

用户只关心人名,不关心头像 URL 和 accountId。

第 8 阶:字段 ID 替换为中文字段名

这是最关键的一步:

// 优化前
"66c976b1054b1ec6bac5803c": "学校荣誉"

// 优化后
"荣誉类别": "学校荣誉"

当 key 是 UUID 时,大模型完全无法理解"这是什么字段"。换成中文名后,大模型能准确地把字段名映射到表格列头。


瘦身效果

查询响应(单条记录)

指标 优化前 优化后 变化
字符数 ~2500 ~200 -92%
有效信息占比 ~10% ~100% -
字段数 15+ 8 -47%

字段结构响应(多选字段,16个选项)

指标 优化前 优化后 变化
Token 数 ~2000 ~450 -78%
包含信息 fieldId + attribute + type(数字) + options(key+value+color) name + fieldType(语义) + options(纯文本) 只留语义信息

这意味着:同样一次查询返回 10 条记录,优化前大模型要处理 25000 字符的噪音数据,优化后只需要处理 2000 字符的纯净数据。


不只是瘦身:还要告诉大模型"边界在哪"

数据精简解决了"噪音太多"的问题,但还有一个问题:大模型不知道自己应该展示多少数据

它可能把 10 条数据渲染成 8 行表格(漏了 2 条),也可能把 10 条数据渲染成 12 行表格(多编了 2 条)。

_meta 元数据:给大模型画个框

我们在每次查询响应中注入 _meta 元数据:

{
  "code": "200",
  "data": [
    {"荣誉类别": "校级荣誉", "荣誉名称": "优秀班级", "获奖时间": "2026-05-01"},
    {"荣誉类别": "个人荣誉", "荣誉名称": "三好学生", "获奖时间": "2026-04-15"}
  ],
  "_meta": {
    "total": 25,
    "page": 1,
    "pageSize": 10,
    "returnedRows": 2,
    "displayFields": ["荣誉类别", "荣誉名称", "获奖时间"]
  }
}

然后在技能定义中约束大模型:

■ 表格行数:严格按 _meta.returnedRows 渲染,不多不少
■ 表格列数:仅使用 _meta.displayFields 中的列名,顺序一致,不得增减
■ 总数引用:使用 _meta.total,不要自行计数

_meta 的本质是:用结构化的元数据,替代大模型的"自行判断"。

大模型不需要数 JSON 数组有几个元素(它数数经常数错),只需要看 returnedRows=2 就知道画 2 行表格。也不需要猜该展示哪些列,只需要看 displayFields 列表。


字段结构接口也要精简:别让大模型被字段定义搞晕

除了查询结果,字段结构定义接口同样需要精简。

低代码平台返回的字段结构长这样:

{
  "controlId": "66c976b1054b1ec6bac5803c",
  "controlName": "荣誉类别",
  "type": 10,
  "attribute": 0,
  "options": [
    {"key": "a1b2c3d4", "value": "校级荣誉", "index": 1, "isDeleted": false, "color": "#2196F3"},
    {"key": "e5f6g7h8", "value": "个人荣誉", "index": 2, "isDeleted": false, "color": "#4CAF50"},
    {"key": "i9j0k1l2", "value": "集体荣誉", "index": 3, "isDeleted": false, "color": "#FF9800"}
  ],
  "isRelation": false,
  "relationTargetWorksheetId": null
}

大模型需要理解 type: 10 是什么意思吗?不需要。需要知道选项的 key: a1b2c3d4 吗?不需要。需要知道颜色码吗?当然不需要。

精简后

{
  "name": "荣誉类别",
  "fieldType": "multi-select",
  "required": false,
  "options": ["校级荣誉", "个人荣誉", "集体荣誉"],
  "selectionHint": "此字段支持多选。请将用户描述与所有选项逐一比对,所有匹配的选项以JSON数组格式传入,不要遗漏任何一个匹配项"
}

做了什么:

改动 原值 新值 理由
移除 controlId UUID 不返回 大模型用中文名传参,后端自动映射
type → fieldType 10 "multi-select" 数字编码无语义,大模型看不懂
移除 attribute 0/1 不返回 内部属性标志,LLM 不需要
options 精简 [{key, value, index, isDeleted, color}] ["校级荣誉", "个人荣誉"] 只留显示名
移除 isRelation boolean 不返回 可从 fieldType 推导
注入 selectionHint 多选提示文案 引导大模型正确处理多选

selectionHint 这个设计值得单独说一下。我们发现大模型在处理多选字段时有一个典型问题:只选第一个匹配项就停了。比如用户说"帮我标记为校级荣誉和个人荣誉",大模型只传了"校级荣誉"。加了 selectionHint 后,大模型会逐一比对所有选项,显著提升了多选场景的准确率。


完整的防幻觉体系:五层纵深防御

响应精简是基础设施层的优化,但要真正控制住幻觉,需要多层协同。我们最终形成了五层纵深防御:

┌─────────────────────────────────────────────────────────────────┐
│  第0层:后端响应精简(地基)                                      │
│  ↓ 消除 90% 噪音,让大模型只看到干净的、有语义的数据               │
│                                                                   │
│  第1层:Prompt 强化(劝告)                                       │
│  ↓ "数据真实性铁律"7条规则,降低捏造意愿                          │
│                                                                   │
│  第2层:强制调工具(拦截)                                        │
│  ↓ 没调工具就敢输出数据?拒绝,强制重试                           │
│                                                                   │
│  第3层:数据锚定(钉死)                                          │
│  ↓ 在 Final Answer 生成前,把关键数据指标钉在上下文最近处          │
│                                                                   │
│  第4层:事后检测(监控)                                          │
│  ↓ 异步比对回复内容与工具返回,记录告警日志                       │
└─────────────────────────────────────────────────────────────────┘

第 0 层是被低估的那一层。 很多团队直接从第 1 层开始——写 Prompt、加约束、搞 Guardrails——但地基没打好,上面的楼再高也不稳。

第 0 层:后端响应精简(本文重点)

  • 8 阶瘦身规则,消除 92% 的噪音
  • _meta 元数据框定表格边界
  • 字段结构语义化 + selectionHint 注入
  • 一句话:让大模型只看到它需要看的,且能看懂的

第 1 层:Prompt 数据真实性铁律

写在 Agent 行为规范中,对所有技能生效的 7 条硬性规则:

1. 先调后说 — 未执行工具前,严禁输出具体数字、日期、名称
2. 所见即所得 — 回复中每个数据值,都能在 JSON 中找到精确对应
3. 空就是空 — total=0 或 data=[] 时直接说"暂无记录"
4. 错就报错 — code≠"200" 时按错误码表回复,不美化
5. 不猜不补 — JSON 中没有的字段,不出现在表格中
6. 不搬不混 — 不把上一轮数据搬到本轮,不把 A 的字段填到 B
7. 不算不统计 — 不自行计算统计数据,除非返回中明确包含

这 7 条规则瞄准的是大模型最常见的 7 种幻觉模式。但 Prompt 是"劝告"性质的,大模型可以理解但不一定遵守。

第 2 层:强制工具执行守卫

在技能定义中声明 require_tool: true,Gateway 层检测:

大模型输出 Final Answer → 检查是否调过工具
  ├── 调过 → 放行
  └── 没调 → 拒绝,强制重试(最多 1 次)
        └── 第 2 轮仍不调 → 放行但追加告警

这一层解决的是最离谱的场景:大模型压根没调工具,直接凭空编了一个表格。

第 3 层:数据锚定注入

在 tool_result 返回后、大模型生成 Final Answer 前,注入锚定指令:

【数据锚定】以下是工具返回的真实数据摘要,你的回复必须严格基于这些数据:
- 记录总数:5
- 本页行数:2
- 展示列:["荣誉类别", "荣誉名称", "获奖时间"]
违反以上任何一项即视为幻觉。

利用注意力机制的近因效应——离生成位置越近的内容权重越高。把关键数据指标放在最后,等于在大模型"下笔"前做最后一次核实。

第 4 层:事后检测引擎

异步比对大模型回复与工具返回的原始数据:

检测规则 检测内容
NO_TOOL_WITH_DATA 未调工具但输出了具体数据表格
ROW_COUNT_MISMATCH 表格行数与 returnedRows 不一致
TOTAL_MISMATCH 声称的总数与 total 不一致
EMPTY_RESULT_WITH_DATA 返回为空但回复包含表格
ERROR_CODE_IGNORED 接口报错但回复呈现为成功

检测结果写入日志表,不阻塞用户体验,但为持续优化提供数据支撑。


一个总结性的思考:为 AI 设计 API vs 为人设计 API

传统后端开发中,API 的设计原则是信息完整——尽量多返回字段,让前端按需取用。反正前端开发者能自己判断哪些字段有用、哪些没用。

但当你的 API 消费者是大模型时,设计原则变成了信息精准——只返回必要的、有语义的信息,并用结构化元数据标注边界。

维度 为人设计 为 AI 设计
字段数量 多多益善,前端按需取 最小够用,减少干扰
字段命名 ID / 英文缩写均可 必须有语义(自然语言 > UUID)
空值处理 返回 null,前端判断 不返回,避免 AI 补全
元数据 前端自己算 必须显式返回(行数、列名、总数)
选项值 返回 key+value 只返回 value(显示名)
错误信息 纯文本报错 结构化候选列表(让 AI 自行纠错重试)

最核心的转变是:从"前端能看懂"到"大模型能看懂"。

人类开发者看到 type: 10 会查文档,看到 fieldId: 66c976b1... 会知道这是内部 ID。但大模型不会查文档,它只能依赖上下文中的语义线索来理解数据——如果你不给它语义,它就会猜,猜就有可能猜错。


落地建议:你现在就可以做的 3 件事

1. 审计你的工具返回

拿出你的 Agent 调用的每一个 Tool,看看 tool_result 里有多少字段是大模型真正需要的。经验法则:如果一个字段不会出现在最终给用户的回复中,就不应该出现在 tool_result 中。

2. 加 _meta 元数据

即使你不做任何瘦身,仅仅加上 _meta: {total, returnedRows, displayFields} 并在 Prompt 中约束大模型使用它们,就能显著减少行数/列数不一致的问题。

3. 把 key 换成自然语言

如果你的 API 返回用 ID 或英文缩写作 key,考虑在中转层做一次翻译。这是投入产出比最高的优化——几行代码,但能大幅降低大模型的"理解成本"。


最后

防幻觉从来不是一个 Prompt 能解决的问题。

Prompt 是"劝告",Guardrails 是"拦截",数据锚定是"核实"——这些都是在大模型"已经看到了脏数据"之后的补救措施。

真正的治本,是不让大模型看到脏数据。

后端响应精简,就是这个"治本"的动作。它不 sexy,不 fancy,不会出现在任何 AI Agent 框架的宣传材料里。但在实际生产环境中,它是幻觉率从"偶尔翻车"降到"基本可控"的关键分水岭


本文基于真实 AI Agent 项目的生产经验。系统对接第三方低代码平台,经过 8 阶响应瘦身后,单条记录体积从 ~2500 字符降至 ~200 字符(减少 92%),字段结构定义 Token 消耗减少 78%,配合五层防御体系,数据类幻觉问题得到有效控制。

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐