Claude Code 实战(二):系统加固 — 权限、Hook、记忆、提示词与错误恢复
Agent 能跑起来只是第一步。真正投入生产,必须先解决安全和稳定问题。本文覆盖系统加固阶段的 5 个关键机制。
前言
在第一篇中,我们搭建了 AI Agent 的核心闭环:Agent 循环、工具使用、待办写入、子代理、技能系统、上下文压缩。
核心闭环跑起来以后,新的问题出现了:
- 模型可能会写错文件、执行危险命令
- 每次加需求都改主循环,代码越来越臃肿
- 每次新会话都从零开始,像"第一次合作"
- 所有东西塞进一个 system prompt,越来越乱
- 一个错误就让整个循环崩掉
本文解决这 5 个问题,对应 5 个章节:
- 权限系统(s07)— 在工具执行前加一道安全闸门
- Hook 系统(s08)— 在固定时机扩展能力,不改主循环
- 记忆系统(s09)— 跨会话保存有价值的信息
- 系统提示词(s10)— 把 prompt 从硬编码升级成组装流水线
- 错误恢复(s11)— 从"报错就崩"升级到"先判断再恢复"
第 7 章:权限系统
"意图不能直接变成执行,中间必须经过权限检查"
问题
到了核心闭环阶段,你的 agent 已经能读文件、改文件、跑命令、做规划。问题也随之出现:
- 模型可能会写错文件
- 模型可能会执行
sudo rm -rf这样的危险命令 - 模型可能会在不该动手的时候动手
四步权限管道
tool_call
|
v
1. deny rules -> 命中了就拒绝
|
v
2. mode check -> 根据当前模式决定
|
v
3. allow rules -> 命中了就放行
|
v
4. ask user -> 剩下的交给用户确认
为什么 deny 先于 allow? 因为有些东西不应该交给"模式"去决定(明显危险的命令、明显越界的路径),这些应该优先挡掉。
三种权限模式
| 模式 | 含义 | 适合场景 |
|---|---|---|
| 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": "matched deny rule"}
# 2. mode
if mode == "plan" and tool_name in WRITE_TOOLS:
return {"behavior": "deny", "reason": "plan mode blocks writes"}
if mode == "auto" and tool_name in READ_ONLY_TOOLS:
return {"behavior": "allow", "reason": "auto mode allows reads"}
# 3. allow rules
for rule in allow_rules:
if matches(rule, tool_name, tool_input):
return {"behavior": "allow", "reason": "matched allow rule"}
# 4. fallback
return {"behavior": "ask", "reason": "needs confirmation"}
Bash 需要特殊对待
所有工具里,bash 最危险。read_file 只能读文件,write_file 只能写文件,但 bash 几乎能做任何事。所以不能只把 bash 当成普通字符串。
建议至少挡住:sudo、rm -rf、命令替换、可疑重定向。
核心思想:bash 不是普通文本,而是可执行动作描述。
第 8 章:Hook 系统
"主循环只负责暴露时机,真正的附加行为交给 hook"
问题
很多需求不属于"允许/拒绝"这条线,而是:
- 在某个固定时机顺手做一点事(比如记录日志)
- 不改主循环主体,也能接入额外规则
- 让用户或插件在系统边缘扩展能力
如果每增加一个需求就去改主循环,主循环就会越来越重,最后谁都不敢动。
Hook 的本质
Hook 理解成一个"预留插口":
- 主系统运行到某个固定时机
- 把当前上下文交给 hook
- hook 返回结果
- 主系统再决定下一步怎么继续
三个核心事件
| 事件 | 时机 |
|---|---|
| SessionStart | 会话开始时 |
| PreToolUse | 工具执行前 |
| PostToolUse | 工具执行后 |
统一返回约定
| 退出码 | 含义 | 作用 |
|---|---|---|
| 0 | 正常继续 | 观察 |
| 1 | 阻止当前动作 | 拦截 |
| 2 | 注入补充消息,再继续 | 补充 |
最小实现
# 事件到处理器的映射
HOOKS = {
"SessionStart": [on_session_start],
"PreToolUse": [pre_tool_guard],
"PostToolUse": [post_tool_log],
}
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": ""}
# 接进主循环
pre = run_hooks("PreToolUse", {"tool_name": block.name, "input": block.input})
if pre["exit_code"] == 1:
results.append(blocked_tool_result(pre["message"]))
continue
if pre["exit_code"] == 2:
messages.append({"role": "user", "content": pre["message"]})
第 9 章:记忆系统
"只有跨会话、无法从当前工作重新推导的知识,才值得进入 memory"
问题
如果一个 agent 每次新会话都完全从零开始,它会不断重复忘记:
- 用户长期偏好
- 用户多次纠正过的错误
- 某些不容易从代码直接看出来的项目约定
- 某些外部资源在哪里找
这会让系统显得"每次都像第一次合作"。
先立一个边界
memory 不是什么都存。 如果你把一切有用信息都记下来,很快就会出现两个问题:
- memory 变成垃圾堆,越存越乱
- agent 开始依赖过时记忆,而不是读取当前真实状态
原则:只有那些跨会话仍然有价值,而且不能轻易从当前仓库状态直接推出来的信息,才适合进入 memory。
4 类适合存储的 memory
| 类型 | 说明 | 示例 |
|---|---|---|
| user | 用户偏好 | 代码风格、回答简洁/详细 |
| feedback | 用户纠正过的地方 | "不要这样改"、"以后先做 X" |
| project | 不容易从代码看出来的项目约定 | 合规要求、不能动的目录 |
| reference | 外部资源指针 | 问题单看板、监控面板 URL |
不要存的东西
| 不要存 | 为什么 |
|---|---|
| 文件结构、函数签名 | 可以重新读代码得到 |
| 当前任务进度 | 属于 task/plan,不属于 memory |
| 临时分支名、PR 号 | 很快会过时 |
| 修 bug 的具体代码细节 | 代码和提交记录才是准确信息 |
| 密钥、密码、凭证 | 安全风险 |
数据结构
每条 memory 一个文件,用 frontmatter 标注元数据:
---
name: prefer_tabs
description: User prefers tabs for indentation
type: user
---
The user explicitly prefers tabs over spaces when editing source files.
索引文件 MEMORY.md 帮系统快速知道"有哪些 memory 可用"。
# Memory Index
- prefer_tabs: User prefers tabs for indentation [user]
- avoid_mock_heavy_tests: User dislikes mock-heavy tests [feedback]
最小实现
MEMORY_TYPES = ("user", "feedback", "project", "reference")
def save_memory(name, description, mem_type, content):
path = memory_dir / f"{safe_name}.md"
path.write_text(frontmatter + content)
rebuild_index()
def load_memories() -> str:
"""会话开始时重新加载,拼成 memory section"""
index = (memory_dir / "MEMORY.md").read_text()
return f"[Memory Index]\n{index}"
初学者最容易犯的错
- 把代码结构存进 memory — "这个项目有 src/ 和 tests/",系统完全可以重新读
- 把当前任务状态存进 memory — "我正在改认证模块",这是 task/plan
- 把 memory 当成绝对真相 — memory 可能过时,优先相信当前观察到的真实状态
第 10 章:系统提示词
"模型看到的不是一坨固定 prompt,而是一条按阶段拼装的输入流水线"
问题
很多初学者一开始会把 system prompt 写成一大段固定文本。一旦系统开始长功能:
- 工具列表会变
- skills 会变
- memory 会变
- 当前目录、日期、模式会变
最小心智模型
把 system prompt 想成 6 段:
1. 核心身份和行为说明
2. 工具列表
3. skills 元信息
4. memory 内容
5. CLAUDE.md 指令链
6. 动态环境信息
拼接:core + tools + skills + memory + claude_md + dynamic_context = final system prompt
最小实现
class SystemPromptBuilder:
def build(self) -> str:
parts = []
parts.append(self._build_core()) # 核心身份
parts.append(self._build_tools()) # 工具说明
parts.append(self._build_skills()) # 技能元信息
parts.append(self._build_memory()) # 记忆内容
parts.append(self._build_claude_md()) # 指令文件链
parts.append(self._build_dynamic()) # 动态环境
return "\n\n".join(p for p in parts if p)
每一段只负责一种来源,职责清晰。
关键边界
稳定说明 vs 动态提醒:
- system prompt:身份、规则、工具、长期约束 — 保持相对稳定
- system reminder:每轮临时需要的补充上下文 — 独立追加
CLAUDE.md 分层叠加:
- 用户全局级
- 项目根目录级
- 当前子目录级
- 全部拼进去,而不是互相覆盖
第 11 章:错误恢复
"错误不是例外,而是主循环必须预留出来的一条正常分支"
问题
到了 s10,系统已经有了主循环、工具、规划、压缩、权限、hook、memory、prompt。一旦真的在做事,错误必然出现:
- 模型输出写到一半被截断
- 上下文太长,请求直接失败
- 网络暂时抖动,API 超时或限流
没有恢复机制,主循环会在第一个错误上直接停住。
3 类问题,3 条恢复路径
LLM call
+-- stop_reason == "max_tokens"
| -> 注入续写提示,再试一次
+-- prompt too long
| -> 压缩旧上下文,再试一次
+-- timeout / rate limit / transient error
-> 等一会儿,再试一次
关键数据结构
# 恢复状态 — 防止无限重试
recovery_state = {
"continuation_attempts": 0, # 续写次数
"compact_attempts": 0, # 压缩次数
"transport_attempts": 0, # 网络重试次数
}
# 续写提示(非常重要!告诉模型不要重来)
CONTINUE_MESSAGE = (
"Output limit hit. Continue directly from where you stopped. "
"Do not restart or repeat."
)
最小实现
def choose_recovery(stop_reason, error_text):
if stop_reason == "max_tokens":
return {"kind": "continue", "reason": "output truncated"}
if error_text and "prompt" in error_text and "long" in error_text:
return {"kind": "compact", "reason": "context too large"}
if error_text and any(w in error_text for w in ["timeout", "rate", "connection"]):
return {"kind": "backoff", "reason": "transient failure"}
return {"kind": "fail", "reason": "unknown error"}
while True:
try:
response = client.messages.create(...)
decision = choose_recovery(response.stop_reason, None)
except Exception as e:
decision = choose_recovery(None, str(e).lower())
if decision["kind"] == "continue":
messages.append({"role": "user", "content": CONTINUE_MESSAGE})
continue
if decision["kind"] == "compact":
messages = auto_compact(messages)
continue
if decision["kind"] == "backoff":
time.sleep(backoff_delay(...))
continue
if decision["kind"] == "fail":
break
总结
系统加固阶段让 Agent 从"能跑"变成"可靠":
| 章节 | 机制 | 核心价值 |
|---|---|---|
| s07 | 权限系统 | 安全闸门,防止危险操作 |
| s08 | Hook 系统 | 可扩展,不改主循环 |
| s09 | 记忆系统 | 跨会话持续学习 |
| s10 | 系统提示词 | 可维护的 prompt 流水线 |
| s11 | 错误恢复 | 遇到错误不崩,先判断再恢复 |
下一篇我们将进入"任务运行时"阶段,学习任务系统、后台任务和定时调度。
常见问题
Q: 权限系统和 sudo 密码有什么区别?
sudo 密码是操作系统层面的控制。权限系统是 Agent 层面的控制——即使模型想执行危险命令,在 API 调用到达 shell 之前就会被拦截。两层防护互不冲突。
Q: Hook 和中间件是一回事吗?
概念类似,但 Hook 更轻量。中间件通常需要修改请求管道,而 Hook 只需注册一个回调函数。Hook 的退出码约定(0=继续, 1=阻止, 2=补充)非常简洁。
Q: 记忆应该存多少条?
少而精。5-10 条高质量的偏好和纠正,远胜过 50 条垃圾信息。原则:如果系统可以重新从代码推导出来,就不要存。
Q: 错误恢复会不会导致无限重试?
不会,因为有计数器限制。recovery_state 跟踪每种恢复类型的尝试次数,超过阈值就停止。防止"续写3次还是截断"这种死循环。
Q: 系统提示词太长会影响模型性能吗?
会的。提示词越长,模型处理越慢,费用越高。所以要把稳定部分和动态部分分开——稳定部分可以被缓存(如 Anthropic 的 prompt caching),动态部分按需追加。详见 Claude Code 配置教程 了解更多优化技巧。
本文基于 Learn Claude Code 开源项目(GitHub: shareAI-lab/learn-claude-code)整理改编。
相关阅读:
相关阅读:
