🔒 从零打造 AI Agent:系统加固篇(S07-S11)

基础打好了,现在要给它装上安全带、加上扩展槽、装上记忆芯片。


🎬 开场:你的 AI Agent 安全吗?

想象你造了一个机器人,帮你在公司里跑腿:

  • 它可以帮你发邮件 ✅
  • 它可以帮你删文件 ❌
  • 它可以帮你在服务器上执行命令 ❌❌❌

如果没有任何限制,机器人可能会:

  • 把重要文件删了
  • 执行危险的 shell 命令
  • 访问不应该访问的数据

更可怕的是:如果它遇到一点小问题就"死机",你得从头来…

这就是为什么我们需要系统加固


📚 上集回顾:核心闭环

上篇文章我们讲了打造 Agent 的基础:

章节 核心问题 解决方案
S01 Agent Loop 如何让 AI 从"会说话"变"会干活" 把现实结果喂回模型
S02 工具使用 如何扩展 Agent 的能力 dispatch map 路由表
S03 待办写入 如何让 Agent 记住"做到哪一步了" pending → in_progress → completed
S04 子代理 如何隔离上下文,防止干扰 新上下文 + 摘要返回
S05 技能系统 如何按需加载专业知识 轻量目录 + 按需加载
S06 上下文压缩 如何保持上下文精简 大结果落盘 + 旧结果缩短 + 整体摘要

今天,我们给这个基础加上安全、稳定、可扩展的能力。


🛡️ S07:权限系统 - 意图先过安全闸门

🎬 精彩开场:保安的故事

想象你住在一个高档小区:

没有保安的情况:

  • 任何人想进就能进
  • 送外卖的可以进
  • 贴小广告的可以进
  • 小偷也可以进(太危险了!)

有保安的情况:

  • 住户 → 直接放行
  • 访客 → 打电话确认
  • 可疑人员 → 拒绝进入

权限系统就是 Agent 的"保安"——工具调用不能直接执行,必须先过一道安全检查。

核心问题

模型可以提出行动建议,但如果直接执行,可能会:

  • 删掉重要文件
  • 执行危险命令
  • 在不该动手的时候动手

解决方案

工具意图不能直接变成执行,中间必须经过权限检查。

四步检查流程

tool_call(工具意图)
    ↓
1. deny rules(优先检查)
    ↓ 命中 → deny(拒绝)
2. mode check(根据当前模式)
    ↓
3. allow rules(白名单)
    ↓ 命中 → allow(放行)
4. ask user(都没命中)
    ↓
最终决策:allow / ask / deny

三种权限模式

模式 含义 适合场景
default 未命中规则时问用户 日常交互
plan 只允许读,不允许写 计划、审查、分析
auto 简单安全操作自动过 高流畅度探索

核心代码

def check_permission(tool_name: str, tool_input: dict) -> dict:
    # 1. deny rules(优先)
    for rule in deny_rules:
        if matches(rule, tool_name, tool_input):
            return {"behavior": "deny", "reason": "危险操作"}
    
    # 2. mode check
    if mode == "plan" and tool_name in WRITE_TOOLS:
        return {"behavior": "deny", "reason": "plan 模式禁止写操作"}
    
    # 3. allow rules
    for rule in allow_rules:
        if matches(rule, tool_name, tool_input):
            return {"behavior": "allow", "reason": "白名单"}
    
    # 4. ask
    return {"behavior": "ask", "reason": "需要确认"}

为什么顺序是 deny → mode → allow → ask

  1. deny 先于 allow:有些东西不应该交给"模式"决定,要优先挡掉
  2. mode 在中间:模式决定当前会话的大方向
  3. allow 在 deny 之后:安全、重复、常见的操作可以直接过
  4. ask 在最后:前面都没命中的灰区,才交给用户

Bash 为什么值得单独讲

bash 是"可执行动作描述",必须额外检查危险模式:

  • sudo
  • rm -rf
  • curl | sh
  • Fork bomb

新手最容易踩的4个坑

  1. deny 和 allow 顺序搞反 → 必须 deny 先于 allow
  2. 不把 bash 当成危险品 → bash 是最危险的工具
  3. 权限系统只是一个开关 → 它是一条管道,不是简单的允许/禁止
  4. 一上来就做很复杂的模式系统 → 先做 3 个模式(default/plan/auto)

🔌 S08:Hook 系统 - 不改主循环也能扩展

🎬 精彩开场:插座的故事

想象你要装修新房:

没有插座的墙:

  • 你想在墙上挂电视 → 没法插电
  • 你想在沙发旁边放灯 → 没地方插
  • 每次想加一个新电器,都要重新拉线

有插座的墙:

  • 电线已经埋好了
  • 墙上留了插座
  • 以后想加什么电器,直接插上去就行

Hook 就是软件的"插座"——主循环提前留好了接口,未来想扩展功能,直接"插上去"就行,不用改主循环。

核心问题

每次想扩展功能都要改主循环,主循环越来越重,最后谁都不敢动。

解决方案

主循环只负责暴露"时机",真正的附加行为交给 Hook。

三种事件 + 返回码

事件 时机 返回码
SessionStart 会话开始 0=继续
PreToolUse 工具执行前 0=继续 / 1=阻止 / 2=注入消息
PostToolUse 工具执行后 0=继续 / 2=注入消息

工作流程

主循环继续往前跑
    |
    +-- 到了某个预留时机
    |
    +-- 调用 hook runner
    |
    +-- 收到 hook 返回结果
    |
    +-- 决定继续、阻止、还是补充说明

核心代码

# 注册 Hook
HOOKS = {
    "SessionStart": [on_session_start],
    "PreToolUse": [pre_tool_guard],
    "PostToolUse": [post_tool_log],
}

# 执行 Hook
def run_hooks(event_name: str, payload: dict) -> dict:
    for handler in HOOKS.get(event_name, []):
        result = handler(payload)
        if result["exit_code"] in (1, 2):
            return result
    return {"exit_code": 0, "message": ""}

统一返回约定

退出码 含义
0 正常继续
1 阻止当前动作
2 注入一条补充消息,再继续

新手最容易踩的4个坑

  1. 把 hook 当成"到处插 if" → 它是预留插口,不是条件分支
  2. 没有统一的返回结构 → 今天返回字符串,明天返回布尔值
  3. 一上来就把所有事件做全 → 先学会 3 个事件,再扩展
  4. 忘了说明"教学版统一语义"和"高完成度细化语义"的区别 → 先学统一模型,再学事件细化

🧠 S09:记忆系统 - 只保存跨会话还成立的东西

🎬 精彩开场:鱼的记忆?

我们都听说过"鱼的记忆只有 7 秒"。但想象一下,如果 AI 也是这样呢?

第一天
你:“我姓李,叫李明”
AI:“好的,李明先生”

第二天
你:“帮我写代码”
AI:“好的!”
你:“你知道我叫什么吗?”
AI:“不知道”

第三天
你:“我最近在学 Python”
AI:“好的!”
你:“我之前告诉过你我的名字是什么吗?”
AI:“不知道”

这样的 AI,你崩溃不崩溃?

核心问题

AI 每次新会话都完全从零开始:

  • 用户姓什么?不记得
  • 之前纠正过什么?不记得
  • 项目有什么约定?不记得

解决方案

只有跨会话仍然有价值、无法从当前工作重新推导的信息,才值得进入 memory。

四类 Memory

类型 解释 示例
user 用户偏好 喜欢 tabs 缩进、回答希望简洁
feedback 用户纠正过的地方 “不要这样改”、“以后遇到这种情况先做 X”
project 不容易从代码推出的项目约定 设计决定是因为合规、某个目录短期不能动
reference 外部资源指针 某个问题在哪个看板、监控面板在哪

什么不要存进 Memory

不要存 为什么
文件结构、函数签名 可以重新读代码得到
当前任务进度 属于 task/plan,不属于 memory
临时分支名、当前 PR 号 很快会过时
修 bug 的具体代码细节 代码和提交记录才是准确信息
密钥、密码、凭证 安全风险

Memory、Task、Plan、CLAUDE.md 的边界

概念 定义 判断方法
memory 跨会话仍有价值的信息 以后很多会话可能还有用?→ memory
task 当前工作要做什么 只对这次任务有用?→ task
plan 这一轮我要怎么做 只对这次任务有用?→ plan
CLAUDE.md 更稳定的规则说明 属于长期系统级固定说明?→ CLAUDE.md

新手最容易踩的5个坑

  1. 把代码结构也存进 memory → 系统完全可以重新读,不需要记
  2. 把当前任务状态存进 memory → 这属于 task/plan,不是 memory
  3. 把 memory 当成绝对真相 → memory 可能过时,要优先相信当前观察
  4. 用户说"忽略 memory"时还继续用 → 当 memory 是空的
  5. 推荐具体路径时不再验证 → memory 只是方向,要验证后再结论

🔧 S10:系统提示词 - 把输入组装成流水线

🎬 精彩开场:组装电脑

想象你要组装一台电脑:

没有流水线的情况:

  • 你买了一个"万能电脑"
  • 里面什么配件都有:CPU、显卡、内存、硬盘、显示器、键盘、鼠标…
  • 全部焊死在主板上,不能换
  • 想升级内存?换电脑

有流水线的情况:

  • 你买了一个"可组装的电脑"
  • 主板上有插槽:CPU 插槽、内存插槽、硬盘插槽…
  • 想升级内存?拔掉旧的,插上新的
  • 灵活、方便、可扩展

System Prompt 流水线就是这种思想:把不同来源的信息分开管理,按需组装。

核心问题

system prompt 写成一坨固定文本,越来越臃肿,维护困难。

解决方案

system prompt 不是一坨固定文本,而是一条按阶段拼装的流水线。

六段组装结构

1. core(核心身份)
2. tools(工具列表)
3. skills(skills 元信息)
4. memory(记忆内容)
5. claude_md(指令链)
6. dynamic_context(动态信息)

然后按顺序拼起来:

core
+ tools
+ skills
+ memory
+ claude_md
+ dynamic_context
= final system prompt

为什么不能把所有东西都塞进一个大字符串

  1. 不好维护:不知道哪一段来自哪里,该修改哪一部分
  2. 不好测试:很难分别测试每个部分
  3. 不好做缓存和动态更新:稳定内容和临时内容混在一起

System Prompt vs System Reminder

类型 适合放什么
System Prompt 身份、规则、工具、长期约束
System Reminder 这一轮才临时需要的补充上下文、当前变动的状态

核心代码

class SystemPromptBuilder:
    def build(self) -> str:
        parts = []
        parts.append(self._build_core())           # 核心身份
        parts.append(self._build_tools())          # 工具列表
        parts.append(self._build_skills())         # skills 元信息
        parts.append(self._build_memory())         # memory 内容
        parts.append(self._build_claude_md())      # CLAUDE.md 指令链
        parts.append(self._build_dynamic())        # 动态信息
        return "\n\n".join(p for p in parts if p)

⚡ S11:错误恢复 - 先恢复,再继续

🎬 精彩开场:司机与路况

想象你是一个司机,开车去一个陌生的地方:

没有错误恢复的情况:

  • 遇到一个红灯 → 停车,不知道接下来怎么办 → 卡住了
  • 遇到修路封路 → 停车,不知道绕哪条路 → 卡住了
  • 遇到导航没信号 → 停车,不知道往哪走 → 卡住了

有错误恢复的情况:

  • 遇到红灯 → 等一等,绿灯亮了继续开
  • 遇到修路封路 → 导航重新规划路线,换条路走
  • 遇到导航没信号 → 缓存上次路线,继续开

错误恢复就是让 AI"遇到问题不卡住",知道该怎么继续。

核心问题

遇到错误就"死机",任务中断,只能从头来。

解决方案

错误先分类,恢复再执行,失败最后才暴露给用户。

三类问题 + 三条恢复路径

问题类型 恢复路径
输出被截断 注入续写提示,再试一次
上下文太长 压缩旧上下文,再试一次
临时连接失败 等一会儿,再试一次

工作流程

LLM call
    |
    +-- stop_reason == "max_tokens"
    |   -> 注入续写提示
    |   -> 再试一次
    |
    +-- prompt too long
    |   -> 压缩旧上下文
    |   -> 再试一次
    |
    +-- timeout / rate limit / transient error
        -> 等一会儿
        -> 再试一次

续写提示为什么重要

# 错误写法
CONTINUE_MESSAGE = "continue"

# 正确写法
CONTINUE_MESSAGE = (
    "Output limit hit. Continue directly from where you stopped. "
    "Do not restart or repeat."
)

核心代码

def choose_recovery(stop_reason: str | None, error_text: str | None) -> dict:
    if stop_reason == "max_tokens":
        return {"kind": "continue", "reason": "输出截断,注入续写"}
    
    if error_text and "prompt" in error_text and "long" in error_text:
        return {"kind": "compact", "reason": "上下文过长,压缩"}
    
    if error_text and any(word in error_text for word in ["timeout", "rate", "connection"]):
        return {"kind": "backoff", "reason": "连接失败,等待重试"}
    
    return {"kind": "fail", "reason": "不可恢复的错误"}

新手最容易踩的5个坑

  1. 把所有错误都当成一种 → 该续写的去压缩,该等待的去重试
  2. 没有重试预算 → 可能无限循环
  3. 续写提示写得太模糊 → 模型可能重新总结
  4. 压缩后不告诉模型"这是续场" → 模型可能重新向用户提问
  5. 恢复过程完全没有日志 → 看不清主循环做了什么

🎯 核心公式总结

系统加固 = 权限系统 + Hook 系统 + 记忆系统 + 提示词流水线 + 错误恢复
权限 = deny → mode → allow → ask
Hook = 事件 + payload + 返回码(0/1/2)
Memory = user / feedback / project / reference
Prompt = core + tools + skills + memory + claude_md + dynamic
Error = 分类 → 恢复(续写/压缩/重试)→ 预算控制

🎯 一句话带走

记住这句话就够了:系统加固的核心是"让 Agent 更安全,更稳定,更可扩展"。权限检查防止危险操作,Hook 允许扩展而不改主循环,Memory 记住跨会话信息,Prompt 流水线组织输入,Error Recovery 让系统遇错不崩。


▶️ 下一步

下一篇文章我们将进入任务运行时部分,学习如何管理复杂项目、让慢任务异步执行、让时间触发工作。

相关文章:


关注我,一起探索 AI Agent 的世界!

Logo

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

更多推荐