AI Agent 防幻觉实战:你的后端接口,才是大模型说谎的帮凶
大家都在 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 个字符是什么?
_id、rowid、caid、uaid、ownerid——系统字段,用户完全不关心66c976b1054b1ec6bac5803c——这是字段 ID,不是字段名avatar、status、accountId——成员字段的冗余属性,用户只需要名字file_id、thumbnail_path、share_url、previewUrl——附件的元数据,一个附件就能膨胀 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%,配合五层防御体系,数据类幻觉问题得到有效控制。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)