AI Agent 生产踩坑实录:8 个案例与防御模式
基于视频云 Agent 平台的实战经验,总结 AI Agent 从 demo 到生产环境中遇到的核心问题、设计模式与最佳实践。
本文所有案例均来自 AI Agent 系统的真实生产环境。
为什么写这篇文章
把 Agent 从「demo 跑通」推到生产环境后,会遇到一系列在开发阶段难以预见的问题------LLM 的 Tool calling 行为并不总是符合预期,上下文管理在长任务中会出现信息丢失,Prompt 的微小差异可能导致截然不同的输出质量。这些问题散落在日常开发的各个角落,本文尝试将它们系统梳理。
文中第 2 章的案例 6-8 来自同一个 Tool(job_complete)的 5 次迭代,记录了从最初的原始实现到最终的四层防御体系的完整演进过程,覆盖了 Tool calling 中三类典型问题:参数格式异常、Description 诱导的语义幻觉、内容与结构的 token 级歧义。类似的防御模式在 OpenClaw、Codex 等开源 Agent 框架中也能看到(详见 2.4 节「业界实践佐证」),这些经验具有一定的通用性。
1. Agent 开发中的常见问题
以下所有案例均来自 Agent 生产系统------一个将剧本自动转化为分镜脚本(包含 ImageGenPrompt / VideoGenPrompt)的 AI Agent 服务。
背景说明: 案例中频繁出现「原始任务」和「回放任务」的对比。线上生产环境使用的是传统的非纯 Agent 流程(固定逻辑、确定性输出);「回放任务」是指将同一份输入交给新 Agent 流程重新执行,用于验证 Agent 的产出质量是否达到线上标准。两者的差异反映的就是 Agent 引入后的质量偏差。
案例 1:指令遵循不完整 --- Skill 懒加载 vs 全文注入
场景: Agent 使用 yike-prompt-generator-wan26 Skill 为分镜生成图像和视频 Prompt。
问题现象: 部分分镜没有使用资产 ID(@xxx)引用主体,而是直接用了角色名文字描述。这违反了 SKILL.md 中的明确规则,且问题可以稳定复现。
根因分析: mpp-agent 中的 aiagent 在启动时加载并注册 ~/.agents/skills 下的 Skills。收到任务 Prompt 后,Agent 不会重读 SKILL.md 全文,而是使用压缩过的 Skill 摘要。Agent 对规则的了解仅有:
- 一定有:system prompt 里的几句摘要(「做 7 步、用 orchestrator、最后 callback」)
- 可能有:Skill 名字 + 简短描述(来自 SDK 的 Skill 发现)
- 只有主动读了才有:通过
file_read读 SKILL.md 后拿到的完整规则
所以,如果 Agent 没有先走一步「读 SKILL」,就只会按摘要执行,容易少做「按 SKILL 逐条检查、再生成」这一步。这也是与 Animus(一上来就注入 SKILL.md 全文)效果差一截的根本原因。
A/B 对比实证:
Prompt 1(懒加载,效果差):
使用 yike-prompt-generator-wan26 skill 处理分镜任务。
参数:- pipeline: i2v-wan26
- input_file: http://mpp-cn-shanghai-pre.oss-cn-shanghai.aliyuncs.com/...
Prompt 2(强制读取全文,效果好):
阅读 /root/.agents/skills/mpp-pipeline-orchestrator/SKILL.md
和 /root/.agents/skills/yike-prompt-generator-wan26/SKILL.md
并使用上述 skill 处理分镜任务。
参数:- pipeline: i2v-wan26
- input_file: http://mpp-cn-shanghai-pre.oss-cn-shanghai.aliyuncs.com/...
真实输出对比(ShotId=76,同一分镜):
| 维度 | Prompt 1(懒加载) | Prompt 2(强制读全文) |
|---|---|---|
| ImageGenPrompt 角色引用 | 林朝朝(米白色小香风套装) ❌ 纯文字 |
[@cedf6520...](米白小香风) ✅ 用了资产 ID |
| 风格描述 | 真人写实风格 |
真实照片质感 |
| 镜头指令 | 缺少 背景适度虚化 等规则要求 |
完整包含 SKILL 规定的构图要素 |
为什么摘要不够用? LLM 的注意力分配是零和博弈(softmax 归一化)。当 Skill 仅以几句摘要存在于上下文中,它获得的注意力权重很低------被分配到了其他更近、更长的信息(如输入数据)上。注入全文后,SKILL 规则获得了足够的权重,指令遵循率显著提升。
修复方案:
- 短期(治标):Prompt 改为先读后用模式
- 长期(治本):agentkit SDK 在 force-load Skill 时,直接将 SKILL.md 内容注入 conversation context
案例 2:上下文压缩导致输出不一致 --- Compactor 的信息损失
场景: 同一份吉卜力风格故事板分镜(ShotId=5,4 个角色:老槐树爷爷、春芽、小禾、小蝶),原始任务和回放任务产出了显著不同的结果。
原始输出(首次执行):
json
{
"ImageGenPrompt": "吉卜力风格,无机械感,无写实效果,绘本风格,梦幻而温暖。
在[@52bb12c0...]中,镜头靠近场景图主体,呈近景镜头,正面视角,背景适度虚化。
[@3fbe4750...](深褐色树皮,枝尖带绿芽)位于画面左侧,上半身入镜;
[@3e70dfc0...](嫩绿裙)位于画面右下,上半身入镜;
[@84c13a30...](草绿襦裙)位于画面右侧中部,上半身入镜;
[@861ece60...](粉白渐变裙)位于画面右上,上半身入镜",
"VideoGenPrompt": "...起始:角色A在画面左侧,角色B在右下,角色C在右侧中部,
角色D在右上;[0--0.5秒][固定机位]老槐树爷爷枝桠缓缓抬起...
[0.5--4.3秒]...老槐树爷爷枝桠轻抚小精灵头顶后指向远处...
[4.3--11秒]...无环境音效和背景音乐。"
}
回放输出(同一输入,再次执行):
json
{
"ImageGenPrompt": "吉卜力风格。
在[@52bb12c0...]中,镜头靠近场景图主体,呈近景镜头,正面视角,背景适度虚化。
[@3fbe4750...](深褐色树皮)位于画面中央,枝桠轻抚下方小精灵头顶;
春芽(头顶绿芽)位于画面左侧三分之一处,腰部以上入镜;
小禾(草绿裙)位于画面右侧;
小蝶(粉白裙)位于画面右前景",
"VideoGenPrompt": "...起始:[@3fbe4750...]在画面中央,春芽在左侧,
小禾和小蝶在右侧围坐。[0--1秒]...
[1--10秒]...
[10--11秒]...音效:跟随画面,禁止生成背景音乐。"
}
逐字段差异分析:
| 差异维度 | 原始 | 回放 |
|---|---|---|
| 风格描述 | 无机械感,无写实效果,绘本风格,梦幻而温暖 |
仅 吉卜力风格(丢失 4 个修饰词) |
| 主体位置 | 左侧/右下/右侧中部/右上(四角分布) |
中央/左侧/右侧/右前景(围坐布局) |
| 角色命名 | 全部使用 [@资产ID](规范) |
混用资产 ID + 角色名文字(不规范) |
| 入镜范围 | 统一 上半身入镜 |
混用 腰部以上 / 上半身 |
| 时间轴分段 | 0--0.5s / 0.5--4.3s / 4.3--11s |
0--1s / 1--10s / 10--11s |
| 音效说明 | 无环境音效和背景音乐 |
禁止生成背景音乐(措辞不同) |
前置说明: 此案例中 Skill 全文已通过 Prompt 2(案例 1 的修复方案)注入,但回放输出仍出现不一致------说明问题独立于 Skill 注入方式,根因在于 Compactor 压缩。
根因: 回放任务时,上下文中包含了前次执行的压缩摘要。Compactor 将 吉卜力风格,无机械感,无写实效果,绘本风格,梦幻而温暖 压缩成了简单的 吉卜力风格------高频细节在压缩中被丢弃。角色位置和时间轴的差异同理:压缩后的上下文丢失了精确的布局信息,LLM 只能重新「创作」,导致与原始输出不一致。
修复方案:
- 关键数据(资产 ID 列表、风格描述全文、角色位置规范)存入 Artifacts(独立于对话历史的 key-value 存储),不参与压缩
- 设计幂等 Skill:每次执行都从输入文件重新读取完整数据,而非依赖上下文中的残留信息
案例 3:Skill 路径幻觉 --- Agent 自造不存在的路径
场景: Agent 被要求使用 yike-prompt-generator-wan26 Skill。
问题现象: Agent 看到该 Skill 的 SKILL.md 标题是「# 图像和视频生成提示词指南」,于是自行将标题翻译成英文 image-and-video-prompt-generation,然后尝试从文件系统读取这个不存在的路径。连续尝试 3 种工具全部失败:
1. file_read("skills/image-and-video-prompt-generation/SKILL.md") → 失败
2. Read("skills/image-and-video-prompt-generation/SKILL.md") → 失败
3. Glob("**/image-and-video-prompt-generation/**") → 失败
→ 放弃,返回空结果 "."
根因的两个层次:
层次 1(根因)------路径解析不一致:
- Skill 从
~/.agents/skills/目录发现并注册 - 但 agentkit 内部告诉 LLM 用相对路径
skills/X/SKILL.md去读 - Read 工具按 CWD(
/Users/levi/wrksp/mpp)解析相对路径 → 找不到文件 - LLM 于是「创造性地」尝试从标题推导路径
层次 2(放大器)------Tool 过多且缺少边界约束:
- Agent 同时拥有
file_read、Read、Bash、Glob等多个工具,能力范围过大 - Read 失败后,没有任何机制阻止 LLM 用 Bash/Glob 去「手动搜索」------Agent 不知道自己的能力边界在哪里,于是尝试一切可用手段
- LLM 执行
ls -la /输出了整个项目目录,产生大量无用 Token,后续推理质量被严重干扰 - 教训:Tool 并非越多越好。 给 Agent 过多工具,等于给它过大的行动空间------它会在这个空间里「探索」,而探索方向往往不可控
修复方案(分优先级):
| 优先级 | 方案 | 说明 |
|---|---|---|
| 紧急 | System Prompt 约束 + 迭代轮次限制 | 如果 Read skill 文件失败,不要搜索文件系统 + 超过 N 轮强制结束 |
| 根本 | Skill 内容预加载 | force-load 时直接注入全文,LLM 无需再 Read 文件 |
| 长期 | 路径绝对化 + Bash guardrail + Token 预算 | 注册 Skill 时用绝对路径;限制 Bash 探索范围;限制单次 tool 输出 Token |
案例 4:URL 字符丢失 --- LLM 无法精确复制长无义序列
场景: Agent 需要从 OSS 下载输入文件。
问题现象: Prompt 中给出的 URL:
http://mts-ai-cn-shanghai-pre.oss-cn-shanghai.aliyuncs.com/mbs-datahub%2Fauto
%2Foutput_file_e7b3ba7fe54c49ffa3c2055b60a43863_1773990601%2Fshot_split_input
.json?Expires=2089350613&OSSAccessKeyId=STS.NXqwqdraK6tLTPzi2NmukaUBw&Signature
=xgGu7BY989X4vWNJQDaFsh%2Btxjg%3D&security-token=CAIS2AJ1q6Ft5B2yfSjIr5vEPM
vQn75qgbanVnbLjTIbYfpHjpDplTz2IHhMe3NoBeEYtvs1nmlT6Pgclrp6SJtIXleCZtF94oxN9h2
gb4fb42AIWwy808%2FLI3OaLjKm9u2wCryLYbGwU...
Agent 在 tool_call 中将 URL 传给 download_url 工具时,丢失了中间的字符------security-token 的部分字符被跳过,导致 OSS 签名校验失败,下载失败。
根因: LLM 自回归生成时,对于长且无语义的 token 序列(如 Base64 编码的签名字符串),逐 token 复制极易出错------这是 LLM 的固有弱点,不是偶发 bug。
修复方案:
- 不要让 LLM 复制长 URL/签名:先用工具下载到本地文件,Prompt 中只引用本地文件路径
- 使用变量引用:
$input_file代替硬编码 URL,由框架层做变量替换
案例 5:选错 Skill --- 输出格式完全不匹配
场景: 原始任务使用 yike-prompt-generator-wan26 Skill 生成分镜 Prompt(输出 ImageGenPrompt + VideoGenPrompt 格式),回放任务应该使用同一个 Skill。
问题现象: 回放任务 60cd8992 的输出格式完全不同于原始任务 9b47b34f:
原始输出(正确格式):
json
{
"ImageGenPrompt": "真实照片质感。在[@3ab02c40...]中,中景,平视...",
"ShotId": "7",
"VideoGenPrompt": "真实照片质感。起始:角色A(学生装)站在教室过道中央..."
}
回放输出(错误格式,选了错误的 Skill):
json
{
"CameraAngle": "平视",
"CameraMovement": "横移",
"CharacterList": [{"Name": "我"}],
"Duration": 3,
"ShotId": 0,
"ShotSize": "中景",
"SpeechList": [{"Emotion": {"Sad": 0.5}, "Gender": "Male", ...}],
"VisualDescription": "镜头从教室左侧开始横移..."
}
逐字段对比:
| 字段 | 原始 | 回放 | 一致 |
|---|---|---|---|
| ImageGenPrompt | 完整的图像生成 Prompt | 缺失 | ✗ |
| VideoGenPrompt | 完整的视频生成 Prompt | 缺失 | ✗ |
| ShotId | 7 |
0 |
✗ |
| CameraAngle | 无此字段 | 平视 |
✗ |
| CharacterList | 无此字段 | [{"Name":"我"}] |
✗ |
| VisualDescription | 无此字段 | 有 | ✗ |
所有字段完全不匹配------Agent 选择了一个错误的 Skill(可能是分镜拆分类 Skill 而非 Prompt 生成类 Skill),产出了完全不同的 JSON Schema。
根因: Skill Catalog 中有 28+ 个 Skill,且多个 Skill 名称相似(yike-prompt-generator-qwen vs yike-prompt-generator-wan26 vs yike-shot-split-dialogue)。LLM 的 softmax 在相似 key 之间难以拉开区分度------名称越相似、数量越多,选择错误的概率越高。
修复方案:
- Scope 隔离 :按业务域过滤 Skill Catalog(
scope: yike→ 只加载 15 个而非 28 个),减少选择干扰 - ForceSkills 显式指定:在 Prompt 中明确指定要使用的 Skill 名称,而非让 Agent 自行匹配
- Skill 命名规范化:让相似 Skill 的命名差异更明显
2. Tool 调用问题 --- 一个 Tool 的 5 次迭代
Agent 通过 Tool 与外部系统交互。但 Tool calling 本质上是让 LLM 逐 token 生成一段结构严格的 JSON------结构越复杂,出错概率越高。以下三个案例来自同一个 job_complete Tool 的连续迭代,完整展示了「发现问题→修复→暴露新问题→再修复」的过程。
案例 6:Tool 参数被包成字符串 --- 重试 8 次仍失败
场景: 分镜生成任务完成后,Agent 调用 job_complete 工具回传 33 个镜头数据。
原始 Tool 定义(出问题时的版本):
go
// Schema --- 5 个参数全部 required,data 声明为 "object"
var jobCompleteSchema = &tool.JSONSchema{
Type: "object",
Properties: map[string]interface{}{
"jobId": {"type": "string", "description": "Job ID (same as subJobId for single-task)."},
"subJobId": {"type": "string", "description": "Sub job / task ID."},
"data": {"type": "object", "description": "Result payload (e.g. array of {ImageGenPrompt,ShotId,VideoGenPrompt})."},
"code": {"type": "string", "description": "Result code, e.g. Success."},
"message": {"type": "string", "description": "Result message, e.g. Successful."},
},
Required: []string{"jobId", "subJobId", "data", "code", "message"},
}
// Description --- 泛泛说明,没有调用示例
func (t *JobCompleteTool) Description() string {
return "Submit task result and close the job. Call once with jobId, subJobId, " +
"and data (your result payload) to finish the task. No need to use Bash or curl."
}
// Execute --- 直接取 key,无任何参数归一化
func (t *JobCompleteTool) Execute(ctx context.Context, params map[string]interface{}) (*tool.ToolResult, error) {
jobId, _ := coerceString(params["jobId"])
subJobId, _ := coerceString(params["subJobId"])
data := params["data"]
code, _ := coerceString(params["code"])
msg, _ := coerceString(params["message"])
if jobId == "" || subJobId == "" || data == nil || code == "" || msg == "" {
return &tool.ToolResult{Success: false,
Output: "jobId, subJobId, data, code, message are all required"}, nil
}
// ... 发送 HTTP POST ...
}
问题现象: Agent 已经正确生成了全部分镜数据,但调用 job_complete 时反复失败 8 次,最终任务超时。
LLM 实际发出的 tool_call(agentkit 日志):
json
// LLM 生成的 arguments 是一段巨大的 JSON 字符串(33 个镜头),
// 因为嵌套过深或格式微瑕,agentkit parseJSONArgs() 反序列化失败,
// fallback 成了:
{"raw": "{\"jobId\":\"job-xxx\",\"subJobId\":\"task-xxx\",\"data\":[{\"ShotId\":1,...},...],\"code\":\"Success\",\"message\":\"Successful.\"}"}
Tool 收到的 params 和返回的错误:
params = {"raw": "<巨大 JSON 字符串>"} ← 只有 1 个 key "raw"
params["jobId"] → nil ← 找不到
params["subJobId"] → nil ← 找不到
params["data"] → nil ← 找不到
→ 返回: "jobId, subJobId, data, code, message are all required"
LLM 重试 8 次,每次都用同样错误的格式 ,因为错误信息 "jobId, subJobId, data, code, message are all required" 没有告诉它「你传了什么」------LLM 不知道自己的参数被包裹在 raw 里了。
为什么 LLM 会生成格式错误的 JSON? Tool calling 本质上是 Decoder 逐 token 生成结构严格的 JSON。对于复杂的嵌套结构(33 个镜头 × 多个字段),模型需要同时追踪 JSON 嵌套层级、key 配对、引号状态------结构越复杂,出错概率越高。而模糊的错误反馈(只说「缺少 jobId」)无法引导 LLM 关注到「JSON 结构是否正确」这个真正的问题。
修复(四层防御):
| 层 | 修复前 | 修复后 |
|---|---|---|
| 参数归一化 | 直接 params["jobId"] 取值 |
检测单 key wrap → 自动展开(unwrapSingleKeyJSON) |
| 错误信息 | "all required" |
"Required: jobId, subJobId, data. Got keys: [raw]. Pass each as a separate top-level key." |
| 必填参数 | 5 个全必填 | 3 个必填,code/message 给默认值 |
| Schema 类型 | data: "type": "object" |
data 去掉 type 约束(实际常传 array) |
核心教训:错误信息是写给 LLM 看的。 Tool 的错误输出会作为 tool_result 回到上下文中,好的错误信息需要三要素:收到什么 + 期望什么 + 怎样修正。
案例 7:Tool Description 中的领域字段名诱导幻觉
场景: 修复案例 6 后再次执行,5 幕并行任务中 2 幕失败、3 幕成功。
案例 6 修复后的 Tool 定义(此时的中间态):
go
// Schema --- 已缩减为 3 个 required,data 去掉了 type 约束
var jobCompleteSchema = &tool.JSONSchema{
Properties: map[string]interface{}{
"jobId": {"type": "string", "description": "Job ID from task context."},
"subJobId": {"type": "string", "description": "Sub job / task ID from task context."},
"data": {"description": "Result data. Can be object or array."}, // ← 无 type 约束
"code": {"type": "string", "description": "Optional. Defaults to Success."},
"message": {"type": "string", "description": "Optional. Defaults to Successful."},
},
Required: []string{"jobId", "subJobId", "data"},
}
// Description --- 加了 Example,但用了领域字段名 ShotId
func (t *JobCompleteTool) Description() string {
return `Submit task result and close the job. Call exactly once.
Example: job_complete(jobId="job-xxx", subJobId="task-xxx", data=[{"ShotId":0, ...}])`
// ^^^^^^^^
// 问题就在这里:ShotId 是业务领域字段名
}
问题现象:
| 幕 | 错误 | LLM 实际传的 data |
|---|---|---|
| 第 1 幕 | Invalid agent result: <class 'str'> |
"[{\"ShotId\":1,...}]" ← JSON 字符串,不是原生 array |
| 第 3 幕 | Invalid agent result: <class 'dict'> |
{"shots": [{...}, ...]} ← 凭空加了 shots wrapper |
| 第 2/4/5 幕 | 成功(碰巧) | 也是 JSON 字符串,但数据 ≥10KB 走了文件中转路径 |
成功的幕也传了字符串,但碰巧走了另一条解析路径------下游按数据大小分三条路径传递,大数据(≥10KB)经过文件中转时会被 json.loads() 二次解析,碰巧修复了类型问题。"碰巧成功"比"全部失败"更危险------部分成功掩盖了根因。
Bug 1:LLM 把 data 传成了 JSON 字符串。 虽然 Schema 没有限制 data 类型,但 LLM 在生成复杂嵌套结构时,倾向于先「序列化」再传------传 "[{...}]" 而不是 [{...}]。Tool 层此时没有做类型归一化,直接透传给下游。
Bug 2:Description 示例中的 ShotId 字段名激活了领域知识。 第 3 幕 LLM 首次失败后重试时,看到示例中的 ShotId,联想到「这是镜头数据,应该用 shots 包一层」,于是返回了 {"shots": [...]}------一个从未在 Schema 中定义的结构。这是一个典型的预训练知识干扰:LLM 在训练中见过大量以 shots 为 key 的数据结构,领域字段名触发了这种关联。
修复(两层防御):
go
// 修复后的 Description --- 去领域化 + 显式声明类型要求
func (t *JobCompleteTool) Description() string {
return `Submit task result and close the job. Call exactly once.
data must be a native JSON object or array, NOT a JSON-encoded string.
Example: job_complete(jobId="job-xxx", subJobId="task-xxx", data=[{"key":"value"}, ...])`
// ^^^^^^^^^^^
// 改为通用占位符,不激活领域知识
}
| 修复 | 修复前 | 修复后 |
|---|---|---|
| data 类型归一化 | 字符串透传 | coerceData(): 自动解析 JSON 字符串为原生类型 + 自动展开单 key wrapper({"shots":[...]} → [...]) |
| Description 去领域化 | data=[{"ShotId":0, ...}] |
data=[{"key":"value"}, ...] + data must be a native JSON object or array, NOT a JSON-encoded string. |
核心教训:Tool 示例中不要使用领域字段名------用通用占位符(key、value、item)代替。 LLM 擅长模仿模式,不擅长理解抽象描述;但模仿时会连同领域联想一起「模仿」。
案例 8:台词引号破坏 JSON 结构 --- 结构字符与内容字符不可区分
场景: 分镜的 VideoGenPrompt 包含中文台词对话。案例 7 修复后,coerceData() 已经能处理 JSON 字符串→原生类型的转换。
案例 7 修复后的 coerceData 逻辑:
go
func coerceData(v interface{}) interface{} {
s, ok := v.(string)
if !ok { return unwrapSingleKeyValue(v) } // 原生类型直接走 unwrap
s = strings.TrimSpace(s)
var parsed interface{}
if json.Unmarshal([]byte(s), &parsed) == nil { // ← 解析 JSON 字符串
return unwrapSingleKeyValue(parsed)
}
return v // ← 解析失败时,原样返回字符串!这就是问题
}
问题现象: LLM 的 tool_call data 中包含台词 画外音:"厌蠢症犯了,这是我见过最蠢的新人。",下游报错:
json.decoder.JSONDecodeError: Expecting ',' delimiter: line 1 column 67
LLM 实际传的 data(JSON 字符串):
data = "[{\"ShotId\": \"57\", \"VideoGenPrompt\": \"画外音:\"厌蠢症犯了\"。结束\"}]"
↑↑ ↑↑
台词中的 ASCII 引号,未被转义
根因链路:
coerceData() 尝试 json.Unmarshal(上面的字符串)
│ 台词中的 " 被 JSON parser 当作字符串结束符 → 解析失败
▼
fallback: 原样返回字符串 v
▼
json.Marshal(body) ← body.Data 是 string 类型
│ Go 的 json.Marshal 对字符串再做一次转义 → 双重编码(double-encoding)
▼
SSE 响应中 data 字段 = "\"[{\\\"ShotId\\\": ...}]\"" ← 消费端拿到的是字符串
▼
消费端 json.loads(SSE payload) → data 是 str → 再 json.loads(data)
│ 此时台词引号变成 bare " → 内层 JSON 解析失败
▼
报错: json.JSONDecodeError 或 Unexpected data format: <class 'str'>
根因: 在 JSON 语法中," 有两种角色------标记 key/value 边界的结构性引号 ,和作为自然语言内容的内容性引号 (如台词 说:"你好")。LLM 自回归生成时,需要同时追踪语义流畅性和 JSON 转义状态,当内容中出现自然语言引号时,模型倾向于保持语义流畅而忽略转义------毕竟预训练中自然语言远多于 JSON 工程。
修复:repairJSONString() --- 在 Tool 层区分两类引号
go
// 修复后的 coerceData --- 增加 repair 层
func coerceData(v interface{}) interface{} {
s, ok := v.(string)
if !ok { return unwrapSingleKeyValue(v) }
var parsed interface{}
if json.Unmarshal([]byte(s), &parsed) == nil {
return unwrapSingleKeyValue(parsed)
}
// ↓ 新增:尝试修复后重试
if repaired := repairJSONString(s); repaired != s {
if json.Unmarshal([]byte(repaired), &parsed) == nil {
return unwrapSingleKeyValue(parsed) // 修复成功!
}
}
return v
}
| 引号类型 | 判断条件 | 示例 |
|---|---|---|
| 结构性引号 | 前/后紧邻 [ { : , ] } |
"ShotId": 中的 " |
| 内容性引号 | 两侧都不是 JSON 结构字符 | 说:"你好" 中的 " |
对内容性引号自动转义为 \",同时将中文智能引号(\u201c \u201d)直接替换为 \"------它们永远是内容引号。
核心教训:不要指望 LLM 自行修复 JSON 转义问题。 台词引号不是「错误」------:"你好" 是自然语言中正常的表达,LLM 不认为自己犯了错。这类结构 vs 内容的歧义,必须在 Tool 层透明修复。
2.4 Tool 设计原则
| 原则 | 来源案例 | 说明 |
|---|---|---|
| 永远假设 LLM 会传错参数 --- 做归一化,不做硬校验 | 6, 7 | 复杂 JSON 结构出错概率高,Tool 层必须容错 |
| 错误信息是写给 LLM 看的 --- 收到什么 + 期望什么 + 怎样修正 | 6 | 模糊反馈导致无意义重试循环 |
| Schema 不能撒谎 --- type/required 必须与代码实际行为一致 | 6, 7 | 严格遵循 Schema 的模型会被错误 Schema 带偏 |
Description 示例要去领域化 --- 用 key/value 代替 ShotId |
7 | 领域术语激活预训练记忆,诱导 wrapper 幻觉 |
| Tool 层必须处理结构/内容歧义 --- 引号修复、类型归一化 | 8 | LLM 无法区分结构性字符和内容性字符 |
| 参数尽量少 --- 能给默认值就给 | 6 | 每多一个 required,出错概率就增加一分 |
| Tool 按需提供,明确边界 --- 不要一次性给 Agent 所有工具 | 3 | Tool 过多会让 Agent 不知道能力边界,失败时倾向于用一切手段「探索」 |
业界实践佐证
上述原则并非我们独有的经验,在其他开源 Agent 框架中也能看到相同的设计模式:
OpenClaw (开源个人 AI 助手,支持 WhatsApp / Telegram / Slack 等多渠道接入,核心也是 LLM + Tool calling 架构)遇到的问题和我们类似:不同 LLM 调用同一个 Tool 时,参数命名习惯不同。比如 Claude 倾向传 file_path,而 OpenClaw 内部的 Tool Schema 定义的是 path------参数值完全正确,但 key 名不匹配就会报「缺少参数」。它的解决方案是 normalizeToolParams() 函数,在 Tool 执行前自动映射参数别名:
typescript
// openclaw/src/agents/pi-tools.read.ts
// file_path → path (read, write, edit)
if ("file_path" in normalized && !("path" in normalized)) {
normalized.path = normalized.file_path;
delete normalized.file_path;
}
// old_string → oldText (edit)
if ("old_string" in normalized && !("oldText" in normalized)) {
normalized.oldText = normalized.old_string;
delete normalized.old_string;
}
同时,所有校验错误都附加固定后缀 "Supply correct parameters before retrying."------对应我们案例 6 中「错误信息是写给 LLM 看的」这条原则。
Codex (OpenAI 的开源编码 Agent,类似 Claude Code)解决的是另一个问题:Tool 执行失败后,应该让 LLM 重试还是直接终止? 比如 LLM 传了错误的参数格式,这类错误可以把错误信息发回让 LLM 自己修正;但如果是系统级错误(如找不到 shell call ID),重试也没用,继续循环只会浪费 token。Codex 在框架层面做了 FunctionCallError 的错误分类:
rust
// codex/codex-rs/core/src/function_tool.rs
pub enum FunctionCallError {
RespondToModel(String), // 可恢复:错误描述发回 LLM,让它纠正后重试
Fatal(String), // 不可恢复:直接终止,避免无意义的重试循环
}
这对应我们案例 6 中 LLM 重试 8 次的场景------如果当时有类似的错误分类机制,在检测到连续相同错误后可以提前终止,而不是等到超时。
2.5 为什么不用 Structured Output 解决案例 6-8?
案例 6-8 的根源都是 LLM 生成 JSON 时出错。当前不少模型已支持 Structured Output / Constrained Decoding(通过约束解码保证输出是合法 JSON)。qwen3.5-plus(通过 DashScope)也支持这一能力。
但在 Agent 模式下无法直接启用------Agent 的每次 LLM 调用不全是结构化输出场景。Agent 运行过程中,模型需要在「自然语言推理」和「Tool calling」之间自由切换:思考下一步该做什么、生成中间推理过程、调用 Tool、处理 Tool 返回结果......如果全程强制 JSON-only,模型就无法进行自然语言推理了。
理论上可以只在最后一次 LLM 调用 (产出最终结果的 tool_call)上启用约束解码,但这面临一个实际困难:如何判断哪次调用是「最后一次」? Agent 的执行流程是动态的,模型自己决定何时调用 Tool、何时结束任务------在调用发生之前,runtime 无法预知这是不是最终的结果调用。
即使未来解决了这个判断问题,Tool 层的防御仍然有存在价值:
- 语义防御不可替代 ------约束解码保证语法合法,但
coerceData()和unwrapSingleKeyValue()处理的是语义层面问题(如 wrapper 幻觉),这些约束解码覆盖不到 - 跨模型兼容性------Agent 可能跑在不同模型上,防御层应该对模型能力做最保守假设
- 务实策略------Tool 层防御为主(处理所有模型),约束解码为辅(在支持的模型上启用作为额外保障)
3. 为什么 Plan 如此重要
3.1 同样的输入,截然不同的输出
同样的模型、同样的 Skill、同样的输入,为什么有时完美有时很差?
我们对比了两种 Prompt 的真实执行效果:
Prompt 1(无 Plan,效果不稳定):
使用 yike-prompt-generator-wan26 skill 处理分镜任务。
参数:- pipeline: i2v-wan26
- input_file: http://...
Prompt 2(有 Plan,效果稳定):
首先下载 http://...,
然后阅读 /root/.agents/skills/mpp-pipeline-orchestrator/SKILL.md
和 /root/.agents/skills/yike-prompt-generator-wan26/SKILL.md,
然后使用上述 skill 处理已经下载的分镜任务,
参数:- pipeline: i2v-wan26,中间结果放在 $jobId 目录
Prompt 2 的核心差异:
- 明确了执行顺序:先下载 → 再读 Skill → 再执行
- 先下载再处理:输入文件已存为本地文件,Skill 处理时不再需要处理 URL
- Skill 放在最后读:利用近因效应,保证 Skill 规则获得最高注意力
效果对比(ShotId=5,吉卜力风格,4 角色):
Prompt 1 的输出中:
- 角色命名混用资产 ID 和角色名文字
- 部分 SKILL 规则被跳过
- 执行耗时 51,871ms
Prompt 2 的输出中:
- 角色全部使用
[@资产ID]引用 ✅ - SKILL 规则被完整遵循 ✅
- 执行耗时 41,574ms(更快,因为没有在探索路径上浪费轮次)
3.2 Plan 解决的是执行过程的随机性
为什么仅仅调整 Prompt 中的执行顺序,就能同时提升质量和速度?
根因在于执行过程的随机性。没有 Plan 时,Agent 的执行顺序是不确定的------有时先看 Skill,有时先看输入,有时跳步执行。Plan 通过显式编排消除了这种不确定性:
- 先下载输入(确保数据就位)
- 再读取 Skill 全文(确保规则在上下文最近处)
- 最后按 Skill 规则处理数据(LLM 最近读到的就是 Skill 规则,遵循概率最高)
3.3 Skill 放在哪里效果最好?
上面的 Plan 实践引出了一个具体问题:Skill 规则应该放在上下文的什么位置?
当前的实践总结:Skill 全文应该尽量靠近模型实际执行的位置。 LLM 对最近读到的信息注意力最高(近因效应),Skill 规则越靠近模型回复位置,被遵循的概率越高。
这个思路与 Codex 的 Skill 加载机制不谋而合。Codex 采用了两层渐进式加载 (源码):
- System Prompt 层:只放 Skill 的名称、描述和文件路径------相当于一个「目录索引」,不占用太多上下文空间
- 用户消息之后:当用户提到某个 Skill 时,框架自动读取 SKILL.md 全文,注入到上下文最末端(紧贴模型回复位置)
Codex 的 Skill 使用指引中明确写道:"After deciding to use a skill, open its SKILL.md. Read only enough to follow the workflow."------先给索引让模型决定用哪个 Skill,真正需要时再加载全文到上下文最近处。
我们的实践和 Codex 殊途同归:核心不是「最后读」这个具体操作,而是确保 Skill 规则在模型执行时处于上下文最近的位置。
回看整个第 3 章------从 Plan 消除执行随机性,到 Skill 放置位置利用近因效应------这些现象是否可以延伸到更一般的层面?人与人之间产出效率与质量的差异,是否也部分来自各自的「系统提示词」------即方法论和思维框架?同样的知识储备、同样的工具、相近的智力水平,不同的执行策略往往导致截然不同的结果。如果这个类比成立,那么 Plan 对 Agent 的意义,和方法论对人的意义或许是相通的:不是让你更聪明,而是让你的聪明用在正确的地方。
4. 防御体系的工程实现
前三章聚焦「问题是什么」和「原则是什么」,本章讲「代码怎么落地」------案例中提到的各项防御在 AgentKit + mpp-agent 系统中的实际实现,以及 Codex 和 OpenClaw 在同类问题上的做法对比。
4.1 上下文管理:Compactor + Artifacts
一次故事板生成涉及 7-12 次 LLM 调用、约 62K Token 消耗。AgentKit 的 Compactor 在 token 用量达到上下文窗口 80% 时自动触发压缩------保留最近 6 条消息和开头 2 条初始消息,中间部分用摘要模型生成压缩摘要:
go
// agentkit/pkg/api/compact.go
type CompactConfig struct {
Threshold float64 // 触发压缩的 token 占比(默认 0.8)
PreserveCount int // 保留最近 N 条消息(默认 6)
PreserveInitial bool // 保留开头消息
InitialCount int // 保留开头 N 条(默认 2)
}
案例 2 暴露了 Compactor 的核心问题:压缩过程会丢失高频细节(吉卜力风格,无机械感,无写实效果,绘本风格,梦幻而温暖 → 吉卜力风格)。解决方案是 Artifacts ------独立于对话历史的 key-value 存储,关键数据(资产 ID、风格描述全文、角色位置规范)存入 Artifacts 后不参与压缩。配合幂等 Skill(每次从输入文件重新读取完整数据,不依赖上下文残留),确保即使压缩发生,执行质量也不受影响。
4.2 Tool/Skill 可见性裁剪
案例 3(路径幻觉)和案例 5(选错 Skill)的根因都指向同一个问题:Agent 能看到的东西太多了。我们在三个层次做了裁剪:
任务级 Tool 白名单
每个任务通过 ToolWhitelist 指定本次可用的 Tool 集合,框架在运行时只注册白名单内的 Tool:
go
// mpp/internal/agent/service/aiagent/runtime_agentkit.go
if session.Params != nil && len(session.Params.Tools) > 0 {
req.ToolWhitelist = session.Params.Tools
}
子 Agent 更严格------每种子 Agent 类型有独立的 Tool 白名单,explore 类型只能 ["glob", "grep", "read"],不能写文件也不能执行命令:
go
// agentkit/pkg/runtime/subagents/manager.go
ToolWhitelist: []string{"glob", "grep", "read"},
Skill Scope 过滤
随着 Skill 增长到 28+,通过 FilterCatalog() 按任务类型过滤可见 Skill 集合,System Prompt Token 减半:
| Scope | 包含 Skills | 对比全量 28 个 |
|---|---|---|
yike |
yike-* (9) + 全局共享 (8) = ~15 | 减少 46% |
mpp-ops |
mpp-ops (5) + 全局共享 (8) = ~13 | 减少 54% |
子 Agent 递归自调用防御
子 Agent 调用 orchestrator Skill 会导致无限递归。CollectOrchestratorSkills() 自动识别名称中包含 orchestrator 或 pipeline 的 Skill 并加入 BlockedSkills------子 Agent 尝试调用时直接拒绝并告知正确做法:
go
// mpp-agentkit/subagent/tool.go
res.Error = fmt.Sprintf(
"skill %q is blocked (recursive self-call). You are the pipeline orchestrator --- "+
"follow YOUR own pipeline steps. Do NOT call yourself via sub_agent.",
task.Skill)
4.3 子 Agent 输出截断
子 Agent 的原始输出(通常 30KB+ 的 JSON)如果完整返回给编排 Agent,会导致编排 Agent 把子任务输出当作最终答案,跳过后续 pipeline 步骤。解决方案:输出写入文件(设为只读 0o444 防止被覆写),只返回一句摘要:
go
// mpp-agentkit/subagent/tool.go
res.Output = fmt.Sprintf(
"Task completed. Output written to %s (%d bytes). "+
"Do NOT read or modify this file. "+
"The pipeline is NOT finished --- continue to the NEXT step immediately.",
task.OutputFile, info.Size())
即使没有指定 output_file,超过 2000 字节的输出也会自动保存并截断------宁可让编排 Agent 看不到细节,也不能让它被大量输出「淹没」而迷失方向。
4.4 Prompt 防御清单
从所有案例中提炼的 Skill 编写和 System Prompt 实操规则:
| 规则 | 来源 | 说明 |
|---|---|---|
| 强制读取全文 | 案例 1 | 阅读 /path/to/SKILL.md 并按规则执行,不要依赖 Skill 摘要 |
| 明确步骤编号 | Plan 实践 | Step 1→2→3→4,不给 Agent 自由发挥的空间 |
| 变量引用代替硬编码 | 案例 4 | $jobId 代替具体值,避免 LLM 复制长字符串出错 |
| 输出格式约束 | 案例 5 | output-mode: json-only,减少格式选择的歧义 |
| 不要依赖 LLM 做精确计算 | 生产经验 | ceil(字数/4.5 + 0.5) 这种计算用 Python 脚本 |
| 限制探索行为 | 案例 3 | Read 失败后不要搜索文件系统 |
System Prompt 中必须包含的防御性约束:
1. 如果 Read skill 文件失败,不要搜索文件系统,skill 内容已在上下文中
2. 不要猜测 Skill 路径,只使用已注册的 Skill 名称
3. 不要执行 ls -la / 或 find / 等全局搜索命令
4. 所有中间文件写入 $jobId 目录,不要使用 /tmp 等共享路径
5. 不要修改 URL/签名等精确数据,原样传递
4.5 业界对比:Codex 与 OpenClaw
上面四节是我们的实际做法。Codex 和 OpenClaw 在同类问题上的思路类似,对照来看:
| 防御层 | 我们的做法 | Codex | OpenClaw |
|---|---|---|---|
| Tool 可见性 | ToolWhitelist 白名单 + FilterCatalog Scope 过滤 |
ConfigShellToolType::Disabled 条件注册;MCP 工具按需加载 |
SUBAGENT_TOOL_DENY_ALWAYS deny list;filterToolsByPolicy() 按角色过滤 |
| 参数防御 | unwrapSingleKeyJSON + coerceData + repairJSONString |
FunctionCallError::RespondToModel vs Fatal(错误分类决定重试/终止) |
normalizeToolParams()(别名映射)+ patchToolSchemaForClaudeCompatibility(Schema 注入别名) |
| 文件访问 | 子 Agent 输出文件设为只读 0o444 |
SandboxPolicy(ReadOnly / WorkspaceWrite / DangerFullAccess)+ ApprovalStore 越权审批 |
wrapToolWorkspaceRootGuard() 校验路径在 workspace 内 |
| 输出控制 | 子 Agent 输出超 2KB 自动截断为摘要 | TruncationPolicy::Tokens(max_tokens) token 级截断 |
executeReadWithAdaptivePaging() 按上下文窗口自适应分页 |
| 递归防御 | CollectOrchestratorSkills() + BlockedSkills |
--- | SUBAGENT_TOOL_DENY_LEAF 禁止叶子 Agent 生成子 Agent |
共同模式:框架层做硬约束(不给就不会乱用),Prompt 层做软约束(告诉不要做什么)。两层配合使用效果最好------框架层覆盖不到的语义级约束(如「不要猜测路径」),仍需 Prompt 兜底。
5. 总结与展望
5.1 面向未来:Agent 工程的保质期
本文记录的许多防御措施------参数归一化、JSON 修复、Skill 位置优化------本质上都是在弥补当前模型的能力缺口。但模型在快速进化:
- 结构化输出能力在增强:从早期的 JSON Mode 到约束解码,模型生成合法 JSON 的能力已大幅提升。但正如 2.5 节分析的,Agent 模式下无法全程启用约束解码------模型需要在自然语言推理和 Tool calling 之间自由切换。即便如此,随着 Tool calling 本身的可靠性提升,案例 6 中的参数格式问题会越来越少见
- 内置 Tool 在增加:当模型原生支持计算、文件操作、数据格式转换等能力时,许多中间层的 Tool 就不再需要
- 上下文窗口在扩大:从 4K 到 128K 到 1M,当窗口足够大时,Compactor 压缩、Skill 懒加载等策略的必要性会降低
- 指令遵循在改善:更强的模型对长 Prompt、复杂约束的遵循能力更好,一些为弱模型设计的 Prompt 工程技巧可能变得多余
- Plan 能力在内化:当前我们需要在 Prompt 中显式编排执行顺序(第 3 章),但随着模型推理能力增强(如 o1/o3 的 chain-of-thought、Claude 的 extended thinking),模型会越来越擅长自主规划。未来 Plan 可能从「用户/开发者手动编写」演变为「模型自主生成 + 人类审核」,甚至完全由模型内化
- Memory 在系统化:当前的三层记忆体系(Session → Long-term → Business Knowledge)需要大量工程支撑,但随着通用 Agent 框架原生支持持久化记忆(如 ChatGPT 的 Memory、Claude 的 Project Knowledge),部分记忆管理工作会下沉到通用 Agent 层,不再需要业务侧自行实现
这意味着半年前有用的设计,半年后可能完全没有必要了 。因此 Agent 的能力层应该采用插件化设计:
- 防御层可插拔:参数归一化、JSON 修复等中间件应该能按需启用/禁用,而不是硬编码到 Tool 实现中
- 按模型能力配置:不同模型需要不同的防御策略------对能力强的模型可以关闭冗余的归一化层,对能力弱的模型则全部启用
- 定期审视和裁剪:随着底座模型升级,主动移除不再需要的防御代码,避免工程复杂度只增不减
Agent 工程的目标不是堆砌防御层,而是用最少的工程量弥补模型当前的能力缺口------当缺口被模型自身填上时,对应的工程就应该退场。
5.2 向下多看一层
回顾全文的 8 个案例,根因反复指向 LLM 的三个底层特性:
- 逐 token 生成(自回归)------LLM 不是「看完问题后一次性写出答案」,而是一个字一个字地往后写。生成复杂 JSON 时,每个 token 都依赖前面的所有 token,结构越复杂、越容易在中途出错(案例 6-8)
- 注意力是有竞争的------Prompt 中的每段信息都在争夺模型的注意力。信息越多、越长,单条指令被关注的权重就越低;越靠近末尾的信息,被关注的概率越高(案例 1、2、3、5)
- 注意力会被误导 ------Prompt 中特定的 token 会把模型的注意力引向错误方向。Description 里一个
ShotId字段名,就足以让模型联想出从未定义过的{"shots": [...]}结构(案例 7);Skill 标题中的中文,就能让模型「创造性地」翻译出不存在的英文路径(案例 3)
这些不是理论知识,而是贯穿所有案例根因分析的底层逻辑。好的工程师总是向下多看一层:写业务代码要懂框架,写框架要懂运行时,写运行时要懂操作系统。每一层的「为什么」,往往藏在下一层的「是什么」里。当 Coding Agent 成为开发者的日常工具,理解 LLM 的工作方式就不再是研究者的专属------它是写好 Prompt 的前提,是设计好 Tool 的基础,是构建可靠 Agent 系统的底层直觉。
参考:
- AgentKit SDK 架构文档(agentkit/docs/current-architecture.zh-CN.md)
- Codex --- OpenAI 开源编码 Agent
- OpenClaw --- Anthropic 开源 AI 助手框架
- Attention Is All You Need --- 论文讲解