防御性编程指南¶
Flyto Agent Engine 的防御性编程实践.面向引擎开发者的内部文档.
目录¶
- 设计理念
- 三层防御模式
- 各模块防御策略
- API 调用
- 工具调用
- 子代理
- 上下文压缩
- 权限系统
- 输入处理
- 会话管理
- Hook 系统
- MCP 协议
- StrictMode 严格模式
- ToolResultPairing 配对修复
- 为什么这样设计
设计理念¶
永远不要信任模型会 100% 遵守指令.对每个模型交互都准备三层防御.
模型是概率性的.即使系统提示词说"不要调用工具",模型仍然可能返回 tool_use 块.即使参数 schema 要求 string,模型仍然可能返回 null.防御性编程是引擎可靠性的基础.
核心原则:
- 不信任模型输出 -- 模型返回的任何东西都可能不符合预期
- 不信任外部输入 -- 工具输出,用户输入,文件内容都可能包含异常数据
- 每个失败路径都有兜底 -- 不存在"走到这里不可能"的代码
- Fail-Open 优于 Fail-Closed -- 宁可带着降级继续运行,也不要直接崩溃
三层防御模式¶
每个和模型交互的地方都应用三层防御:
第一层:指令层(Instruction)
在提示词中明确告诉模型不要做什么。
示例:压缩提示词中说"不要调用工具,只生成摘要文本"。
第二层:参数层(Parameter)
在 API 参数中阻止不期望的行为。
示例:压缩 API 调用时不传 tools 参数,从 API 层面阻止工具调用。
第三层:兜底层(Fallback)
即使前两层都失败了,代码中仍然正确处理。
示例:压缩响应中如果出现 tool_use 块,当作普通文本处理而不是执行。
三层防御的价值在于:
| 场景 | 只有指令层 | 指令+参数层 | 三层防御 |
|---|---|---|---|
| 模型遵守指令 | 正常 | 正常 | 正常 |
| 模型无视指令 | 工具被执行 | API 报错 | 优雅降级 |
| 模型无视指令 + API 不拦截 | 工具被执行 | 工具被执行 | 优雅降级 |
各模块防御策略¶
API 调用¶
与模型 API 通信是最关键的交互点.
空响应
代码位置:pkg/engine/engine.go runLoop 中处理 message_stop 事件时
不完整的 JSON(tool_use input)
tool_use 块的 input 字段通过多个 input_json_delta 事件逐步拼接.如果 SSE 流中断,JSON 可能不完整.
代码位置:internal/api/client.go parseSSE, pkg/tools/orchestrator.go 执行前的 JSON 解析
SSE 流中断
429/529 重试
指令层:无
参数层:无
兜底层:
- 429(速率限制):指数退避重试,最多 3 次
- 529(过载):前台请求重试,后台请求直接失败(防止级联雪崩)
- 401:清除可能的缓存 → 提示用户检查 API key
stop_reason 不可靠
指令层:无
参数层:无
兜底层:不仅依赖 API 返回的 stop_reason,引擎自己追踪 content block 类型。
如果收到了 tool_use block,即使 stop_reason 不是 "tool_use",
也进入工具执行流程。
工具调用¶
工具不存在
模型可能请求一个不存在的工具(拼写错误或幻觉).
指令层:系统提示中列出所有可用工具
参数层:API tools 参数只包含注册过的工具
兜底层:Registry.Get() 返回 nil → 返回友好错误消息
"Tool 'XxxTool' not found. Available tools: Bash, Read, Edit, ..."
模型可据此调整。
代码位置:pkg/tools/orchestrator.go ExecuteBatch
工具输入 JSON 无效
指令层:每个工具的 description 中说明 required 字段
参数层:InputSchema() 返回 JSON Schema(API 侧验证)
兜底层:json.Unmarshal 失败 → 返回 schema 提示
"Invalid input for Bash tool. Expected: {command: string, ...}"
不 panic,不 crash。
工具执行超时
指令层:description 中说明超时限制
参数层:context.WithTimeout 硬限制
兜底层:超时后优雅终止(SIGTERM → 5s → SIGKILL → 进程组)
返回已收集的部分输出 + "[command timed out after Xs]"
代码位置:pkg/tools/builtin/bash.go executeForeground
工具返回非 UTF-8
指令层:无
参数层:无
兜底层:isBinaryContent() 检测 null bytes / 无效 UTF-8 / magic bytes
检测到二进制 → 替换为 "Binary output (N bytes). Pipe to file if needed."
工具结果超大
指令层:无
参数层:无
兜底层:
1. stdout/stderr 独立截断(stdout 200KB, stderr 56KB, 总计 256KB)
2. 截断保留头 80% + 尾 20%(保留开头的结构和结尾的总结)
3. 超过 MaxInlineResultChars (30000) → 存磁盘 → 模型拿到摘要 + 路径
代码位置:pkg/tools/builtin/bash.go truncateStream, pkg/engine/result_store.go
子代理¶
递归生成防护
子 Agent 不能递归创建子 Agent(无限递归将耗尽资源).
代码位置:pkg/engine/subagent.go
子代理超预算
子代理失败
指令层:无
参数层:独立 context(子代理 panic 不传播到父代理)
兜底层:recover → 返回错误摘要给父 Agent
"Sub-agent failed: <error summary>"
上下文压缩¶
压缩时不应调用工具
指令层:压缩提示词开头说"Do NOT call any tools. Only output a text summary."
参数层:压缩 API 调用不传 tools 参数
兜底层:如果响应中仍出现 tool_use block → 提取 input JSON 作为文本内容
代码位置:pkg/context/compact.go
压缩失败
指令层:无
参数层:无
兜底层:电路断路器模式:
- 第 1 次失败:重试
- 第 2 次失败:降级到 MicroCompact(简单截断旧工具结果)
- 第 3 次失败:停止尝试压缩,发出 WarningEvent
压缩结果为空
权限系统¶
权限 Handler 超时
代码位置:pkg/permission/checker.go
权限 Handler 返回异常
权限规则解析失败
代码位置:pkg/permission/rules.go
输入处理¶
文件引用不存在
图片读取失败
输入过大
代码位置:pkg/engine/input.go
会话管理¶
会话保存失败(磁盘满)
会话恢复文件损坏
代码位置:pkg/engine/session_persist.go(原子写入:先写 .tmp 再 rename)
并发 Send 到同一 Session
代码位置:pkg/engine/session.go
Hook 系统¶
Hook 是用户配置的外部 shell 命令,不可信任.
Hook 命令不存在
Hook 输出非 JSON
Hook 超时
代码位置:pkg/hooks/executor.go
MCP 协议¶
MCP 服务器是外部进程,可能随时崩溃.
MCP 服务器启动失败
MCP 工具调用超时
MCP 服务器崩溃
代码位置:internal/mcp/client.go, internal/mcp/manager.go
StrictMode 严格模式¶
pkg/engine/strict.go
StrictMode 是防御性编程的"审计模式".生产环境中引擎静默修复各种异常,但安全评估和测试环境需要知道这些异常的存在.
背景(inc-4977)¶
安全评估时引擎静默注入了合成 tool_result,导致模型看到的上下文与实际不一致,评估结果不可信.StrictMode 解决这个问题:开启后,所有静默修复变成 panic,保证评估环境的上下文纯净.
与防御性编程的关系¶
StrictMode 不是否定防御性编程,而是让防御行为可控:
生产环境(StrictMode nil):
异常 → 静默修复 → Observer.Event 记录 → 用户无感知
安全评估(StrictMode 全开):
异常 → panic → 评估中断 → 开发者立即知道问题
测试环境(StrictMode 部分开):
配对异常 → panic(必须精确)
压缩失败 → 降级(允许不精确)
Check 模式¶
StrictMode.Check(condition, enabled, observer, detail) 统一了严格模式和可观测性:
- enabled=true -- panic("strict mode violation: ...")
- enabled=false -- observer.Event("strict_mode_would_fail", ...)
每个调用点只需一行代码,不需要自己写 if/else + observer.Event().
三个检查维度¶
| 方法 | 条件 | 调用位置 |
|---|---|---|
CheckToolResultPairing |
tool_use/tool_result 配对异常 | ToolResultPairingNormalizer |
CheckCompactFailure |
压缩失败 | CompactTiered |
CheckNormalizerError |
规范化异常 | NormalizePipeline |
代码位置:pkg/engine/strict.go
ToolResultPairing 配对修复¶
pkg/engine/norm_tool_result_pairing.go
tool_use/tool_result 配对错误是生产中最常见的 API 400 根因.早期方案只处理"孤立 tool_result"(OrphanToolResultRemover),实际还有 3 种 case.
4 种防御场景¶
Case 1: tool_use 无 tool_result(API 400 防护)
原因:会话中断、压缩截断、异常退出
后果:API 返回 400(tool_use 必须有对应 tool_result)
修复:注入合成 tool_result,标记 IsError=true
文案:"[Tool result not available - session may have been interrupted...]"
Case 2: tool_result 无 tool_use(委托)
Case 3: 重复 tool_use ID(去重)
Case 4: 重复 tool_result ID(去重)
与可观测性的集成¶
每次修复都通过 Observer 记录完整信息:
observer.Event("tool_result_pairing_repaired", {
"repairs": ["synthetic_tool_result:tu_001", "duplicate_tool_use:tu_002"],
"repair_count": 2,
"message_count_before": 5,
"message_count_after": 6,
"diagnostic": "[0] user(text); [1] assistant(tool_use=[tu_001,tu_002]); ..."
})
诊断快照只包含结构信息(角色,content 类型,ID),不含消息内容,可安全发到外部监控.
Priority 排序的防御意义¶
ToolResultPairingNormalizer 的 Priority 为 8,在 OrphanToolResultRemover(10) 之前执行.顺序不能反:先注入合成 tool_result,再由 OrphanToolResultRemover 清理孤立的--如果反过来,注入的合成 tool_result 不会被二次检查.
代码位置:pkg/engine/norm_tool_result_pairing.go
为什么这样设计¶
踩坑经验¶
-
模型返回空 content block -- 早期版本直接 panic(index out of range).修复后加入空响应检测和重试.
-
Bash 输出撑爆上下文 -- 一次
npm install输出 500KB 日志,全部送入下一轮 API 调用导致超 token 限制.修复后实现分离截断策略和 ResultStore 磁盘持久化. -
子 Agent 递归 -- 模型在子 Agent 中调用 Agent 工具创建孙 Agent,无限递归直到 OOM.修复后三层防御:提示词 + 工具过滤 + 深度检测.
-
权限 Handler 不返回 -- HTTP 模式下客户端断连后 PermissionHandler 永远不返回,goroutine 泄漏.修复后加入超时默认拒绝.
-
压缩时调用工具 -- 模型在压缩调用中返回 tool_use(无视了"不要调用工具"的提示).修复后不传 tools 参数 + 兜底处理.
-
tool_use 缺少 tool_result 导致 API 400 -- 会话中断后恢复,压缩截断了 tool_result 但保留了 tool_use,后续 API 调用全部 400.ToolResultPairingNormalizer 注入合成 tool_result 解决.
-
安全评估结果不可信(inc-4977) -- 引擎静默注入合成 tool_result 后,模型基于合成内容做评估,结果偏差.StrictMode 让评估环境 panic 而不是静默修复.
-
重复 tool_use ID 导致不可预测行为 -- 压缩合并后出现重复 tool_use ID,API 响应行为不确定.ToolResultPairingNormalizer 去重修复.
设计权衡¶
-
Fail-Open vs Fail-Closed: Hook 系统选择 fail-open(Hook 失败不阻塞主流程),因为 Hook 是增强功能而非核心功能.权限系统选择 fail-closed(异常时拒绝),因为安全是刚性需求.
-
重试 vs 快速失败: 前台 API 调用(用户等待中)选择重试,后台调用(子 Agent,Dream 等)选择快速失败,防止级联雪崩.
-
降级 vs 停止: 能降级就降级.压缩失败降级到 MicroCompact,MicroCompact 失败降级到不压缩 + 警告.只有所有降级路径都穷尽了才停止.