作者: Jinkun
时间: 2026年4月
项目地址: github.com/yaoziyaoguai/my-first-agent
前置阅读: 上篇《从零构建 AI Agent(一):Context Engineering 与 Harness Engineering 实战手记》
一、上篇结束时的状态
上篇完成了从 10 行 API 调用到 800 行模块化 Agent 系统的全过程,包括:Agent Loop、消息记忆、6 种工具、分级权限、源码保护、上下文压缩、跨模型审查、自动重试、eval → AST 安全加固、以及从单文件到 7 个模块的架构重构。
上篇末尾列了四个"下一步":敏感文件保护、Shell 命令执行、时序分布、工具插件化。本篇就是逐一兑现这些计划的过程——以及在实现过程中冒出来的、计划之外的问题和解决方案。
二、Shell 命令执行:给最危险的工具穿最厚的甲
为什么 Shell 是特殊的
上篇建立的风险分级里,Shell 命令排在最顶端:
风险最低 计算器 → 无副作用 → 静默执行
读项目内文件 → 无副作用 → 静默执行
读项目外文件 → 可能涉密 → 需确认
写文件 → 可逆(有备份) → 需确认
风险最高 Shell 命令 → 可能不可逆 → ???
对于"???"这个位置,上篇的结论是"光确认不够"。用户可能走神点了 y,一条 rm -rf / 就没有回头路了。核心原则是:风险越高,控制层数越多。
四层纵深防护
| 层级 | 类型 | 防什么 |
|---|---|---|
| 命令字符串黑名单 | Guide,计算型 | 直接的危险命令(rm -rf、sudo、shutdown) |
| 脚本内容检查 | Guide,计算型 | 藏在 .sh 文件里的危险命令 |
| 脚本内容展示 + 人类确认 | Guide,计算型 | 让人类看清楚再决定 |
| 超时自动终止 | Sensor,计算型 | 死循环和命令卡死 |
第二层的诞生源自一个自然的追问:如果用户让 Agent 执行 bash evil.sh,黑名单检查的是 "bash evil.sh" 这个字符串——里面没有 rm -rf,放行了。但脚本文件里面可能藏着危险命令。
解决方案:当命令看起来是在执行脚本(bash xxx.sh、python xxx.py)时,先读取脚本内容做黑名单检查,并在确认框里把脚本内容展示给用户。
局限性的坦诚
这种静态检查只能防御明文可见的危险命令。如果脚本里用字符串拼接或 Base64 编码构造命令,黑名单检测不到。真正的安全需要沙箱隔离——但那是更大的工程。当前策略是:通过严格的 Guides 降低风险,而不是靠沙箱兜底。
三、时序分布:质量左移的四个阶段
核心概念
Harness 原文(Martin Fowler 网站,Birgitta Böckeler)中有一个重要维度:控制手段不是全部挤在同一个时间点,而是沿着变更的生命周期分布。
关键认识:不是"检查点越多越好",而是每个阶段放最适合它的检查——快的便宜的放左边,慢的贵的放右边。
第一阶段:Agent 运行时——linter 即时反馈
当 Agent 写完一个 .py 文件后,自动跑 ruff check,把结果追加到返回给模型的 tool_result 里。
这里有一个逻辑冲突需要处理:linter 反馈和停止指令不能同时注入。如果 linter 发现问题,应该让 Agent 继续修复,不注入停止指令;只有 linter 通过了才停下来。
从 Harness 角度看,ruff 的输出是一个 计算型 Sensor 的信号,通过 Context Engineering(注入到 tool_result 里)驱动 Agent 自动修复。原文专门提到:好的 Sensor 应该产生"对 LLM 消费友好的信号"。ruff 的输出天然具备这个特点——指出行号、错误类型、修复建议。
第二阶段:提交前——pre-commit hook
创建了 .pre-commit-hook.sh 安装为 Git hook,只检查暂存区里的 .py 文件(通过 git diff --cached --name-only 实现)。
这一层立刻发挥了价值——抓住了代码里的未使用 import 和多余的 f-string 前缀。计算型 Sensor 比人可靠,因为它不会疲劳、不会走神。
一个工程细节:虚拟环境里的 ruff 在 Git hook 中找不到,因为 hook 运行时没有激活虚拟环境。解决方法是用完整路径 .venv/bin/ruff。
第三阶段:CI/CD
上篇之前搭建的 GitHub Actions 流水线。
第四阶段:启动时——健康检查
每次 python main.py 启动时自动运行四项检查:workspace 下 Python 文件的 lint 状态、.bak 备份文件堆积情况、日志文件大小、session 快照数量。
这些检查跟前三层的区别:不是在某次变更时触发,而是对整体环境做健康扫描。 就像每次"上班"之前先做一次体检。
时序分布的价值
时间点 做什么 修复成本
Agent 运行时 ruff 即时检查 秒级(Agent 自修复)
提交前 pre-commit hook 分钟级(开发者本地修)
CI/CD 全量检查 小时级(等流水线跑完)
启动时 健康扫描 取决于积累的问题量
核心不是"检查多",而是越早发现修复成本越低。
四、敏感文件保护:堵住所有路径
问题的发现
Agent 可以通过 read_file 读取项目目录下的 .env 文件——里面有 API Key。文件内容进入 messages 后,会被发送到第三方 API 服务器。即使在本地使用场景下,这也是一个真实的泄露风险。
三态确认机制
原来的 needs_confirmation 只返回 True(需确认)或 False(静默执行)。对于敏感文件,需要第三种状态——直接拒绝,用户说 y 也不行。因为有些操作不应该给人类犯错的机会。
# 三种返回值
False → 安全,静默执行
True → 有风险,弹确认
"block" → 危险,直接拒绝
多路径封堵
加了 read_file 的拦截后,测试发现 Agent 用 run_shell("cat .env") 绕过了——跟之前 calculate 被拦后 Agent 试图写脚本是同一个模式。
教训:安全控制要覆盖所有能达到同一目标的路径,不能只堵一条。 在 run_shell 里也加了敏感文件关键词检测。
模式匹配而非精确列举
不是列出具体的文件名(.env、secrets.yaml),而是用模式匹配:以 .env 开头的文件名、包含 secret/credential/password/token 等关键词的文件名。这样 .env.local、.env.production、db_credentials.yaml 都能被自动覆盖。
五、工具插件化:从五处修改到一次注册
问题
每加一个新工具,需要改五个地方:
tools.py:写函数实现tools.py:execute_tool加 elif 分发tools.py:TOOL_DEFINITIONS加描述 JSONconfig.py:ALLOWED_TOOLS加名字security.py:needs_confirmation加判断
漏一个就出 bug——之前 fetch_url 忘了改 config.py 就被白名单拦住了。
Python 装饰器的应用
装饰器的本质是"在函数定义时顺手做一件额外的事(注册),但不影响函数本身"。
TOOL_REGISTRY = {}
def register_tool(name, description, parameters, confirmation="always"):
def decorator(func):
TOOL_REGISTRY[name] = {
"name": name,
"description": description,
"parameters": parameters,
"confirmation": confirmation,
"func": func,
}
return func # 原样返回,不改变函数
return decorator
使用时,一个文件包含工具的一切——实现、描述、确认规则:
@register_tool(
name="calculate",
description="计算数学表达式。仅在用户明确要求时使用。",
parameters={"expression": {"type": "string", "description": "数学表达式"}},
confirmation="never",
)
def calculate(expression):
# 实现...
注册中心自动提供三个函数:get_tool_definitions()(给模型看的描述列表)、get_allowed_tools()(白名单)、execute_tool()(统一分发)。五处修改变成了零处修改——写一个文件,一切自动生效。
确认规则的灵活化
confirmation 参数支持三种值:
"always"→ 全部确认(write_file、run_shell、fetch_url)"never"→ 从不确认(calculate)- 一个函数 → 调用这个函数来动态判断(read_file 的路径权限检查)
传函数比传字符串更灵活——不同工具可以有完全不同的确认逻辑,而且同一个确认函数可以被多个工具共用(read_file 和 read_file_lines 用的是同一个 _check_read_permission)。
目录结构的变化
之前:
agent/
├── tools.py ← 所有工具挤在一个 400 行的文件里
现在:
agent/
├── tool_registry.py ← 注册中心(通用机制)
├── tools/
│ ├── __init__.py ← 导入所有工具,触发注册
│ ├── calc.py ← 计算器
│ ├── file_ops.py ← read_file + read_file_lines
│ ├── write.py ← write_file
│ ├── shell.py ← run_shell
│ ├── web.py ← fetch_url
│ └── outline.py ← 多格式文件结构提取
六、Pre/Post Execute 钩子:彻底解耦
残余的耦合
插件化之后,core.py 里仍然有四段 if tool_name == "write_file" 的硬编码:
# 1. 同一轮只允许一次 write_file
# 2. 源码保护检查
# 3. linter 自动检查
# 4. 停止指令注入
如果以后加一个 edit_file 工具,又要在 core.py 里加类似的逻辑。
钩子的设计
在工具注册时多传两个函数——pre_execute(执行前钩子)和 post_execute(执行后钩子):
@register_tool(
name="write_file",
confirmation="always",
pre_execute=pre_write_check, # 源码保护、同轮重复写检查
post_execute=post_write_check, # linter 检查、停止指令注入
)
def write_file(path, content):
# 纯粹的写入逻辑,不掺杂任何检查
tool_registry.py 里的 execute_tool 变成:
def execute_tool(name, tool_input, context=None):
info = TOOL_REGISTRY[name]
# 执行前钩子:可以拦截
if info.get("pre_execute"):
block_reason = info["pre_execute"](name, tool_input, context)
if block_reason:
return block_reason
# 执行工具函数
result = info["func"](**tool_input)
# 执行后钩子:可以修改结果
if info.get("post_execute"):
result = info["post_execute"](name, tool_input, result)
return result
core.py 只管调 execute_tool,不需要知道任何工具的名字。Agent Loop 的流程控制和工具的专属逻辑彻底分离。
七、网络访问:新能力带来新的安全维度
动机
开发文档都在线上,Agent 读不到。需要一个 fetch_url 工具。
三个新风险
本地文件操作的风险是隐私泄露和数据损坏。网络访问引入了三个新维度:
| 风险 | 控制手段 |
|---|---|
| 恶意内容 / Prompt Injection | 剥掉 script、style、nav 等标签,只提取正文 |
| 网页不可达 / 超时 | 15 秒超时,不自动重试 |
| 内容量爆炸 | 超过限制自动存为本地文件,复用 read_file_lines 分段读 |
复用已有能力
大页面的处理策略跟大文件分段读取是同一个 Context Engineering 思路:先给概览,按需深入。 网页内容超限时自动保存到 workspace/fetched_xxx.txt,Agent 如果需要更多内容,用已有的 read_file_lines 分段读取。不加新接口,复用已有工具。
八、工具描述的精细调优
误触发:文档里的 5+5
Agent 在读取一个包含 5+5 字符串的文档时,自动调用了计算器。原因是工具描述太简短——"计算一个数学表达式" 没有告诉模型什么时候不该用。
修复:在工具描述里加反面约束——"不要对文档内容、文件中出现的数字或表达式主动调用此工具"。经验是:反面约束往往比正面描述更有效,因为模型的默认倾向就是"有工具就想用"。
大文件概览后的反复重试
read_file 对大文件返回概览而非完整内容,但 Agent 误以为"没读到文件"而反复用不同路径重试。把返回信息从 [文件概览] 改为 [读取成功 - 文件较大,以下为概览],并在末尾明确说"不要重复调用 read_file"后问题消失。
Context Engineering 的细节:你注入的每一个字都会影响模型的行为。
九、两层 Harness 的区分
在实践过程中我一度感到困惑:pre-commit hook 检查的是"我写的代码",linter 检查的是"Agent 写的代码",它们都叫 Harness,但感觉不是一回事。
厘清后发现确实是两个层面:
层面一:控制开发者(你自己)。 pre-commit hook、CI/CD、启动健康检查——它们保障的是 Agent 项目本身的工程质量。
层面二:控制 Agent。 权限分级、源码保护、审查系统、linter 自动检查——它们保障的是 Agent 输出的可靠性。
Harness 原文讲的主要是层面二——怎么控制一个 coding agent 的输出。两层都有价值,但不应该混为一谈。
十、关于"冗余"的思考
重构过程中注意到 write_file 函数内部和 core.py 都有源码保护检查,问了一个问题:这是不是冗余了?
答案是:职责不同的两层防线,不是冗余。
core.py的检查(现在移到了pre_execute钩子):Guide,优化体验——让不可能成功的操作根本不弹确认框write_file函数内的检查:安全兜底——万一第一层被绕过,函数本身也会拒绝
就像银行的门禁和金库锁,你不会因为有了门禁就不锁金库。
十一、踩坑补充
| 问题 | 根因 | 解决方案 |
|---|---|---|
| ruff 在 Git hook 中找不到 | 虚拟环境未激活 | 用完整路径 .venv/bin/ruff |
| pre-commit 检查了 .gitignore 里的文件 | 检查了整个 workspace | 改用 git diff --cached 只检查暂存区 |
| config.py 改了但 Agent 没生效 | 改完没保存 | 保存后重启 |
| fetch_url 被白名单拦截 | config.py 里忘加名字 | 插件化后自动注册,不再有此问题 |
| linter 反馈和停止指令同时注入 | Agent 收到矛盾指令 | 有 linter 问题时不注入停止指令 |
| 计算器被文档内容误触发 | 工具描述缺乏反面约束 | 加上"不要对文档内容主动调用" |
| import 不在文件顶部(E402) | 边写边加的 import 位置随意 | 统一移到顶部 |
| Agent 用 cat .env 绕过 read_file 拦截 | 只堵了一条路径 | 在 run_shell 里也加敏感文件检测 |
| 装饰器触发的导入被 ruff 标记为 unused | 导入是为了副作用 | 用 noqa: F401 标注 |
十二、最终项目架构
my-first-agent/
│
├── config.py ← 配置中心
├── main.py ← 入口 + 启动健康检查
├── .pre-commit-hook.sh ← 提交前检查
│
├── agent/
│ ├── core.py ← Agent Loop(不含任何工具专属逻辑)
│ ├── tool_registry.py ← 注册中心 + execute_tool(含钩子)
│ │
│ ├── tools/ ← 插件化工具
│ │ ├── calc.py ← calculate(AST 安全版)
│ │ ├── file_ops.py ← read_file + read_file_lines
│ │ ├── write.py ← write_file + pre/post 钩子
│ │ ├── shell.py ← run_shell(四层防护)
│ │ ├── web.py ← fetch_url
│ │ └── outline.py ← 多格式结构提取
│ │
│ ├── security.py ← 通用安全(确认、敏感文件、脚本检测)
│ ├── context.py ← 上下文压缩
│ ├── review.py ← 跨模型审查 + 自动重试
│ ├── checks.py ← 运行时 linter 检查
│ ├── health_check.py ← 启动健康检查
│ └── logger.py ← 日志 + 快照
│
├── Harness 时序分布
│ ├── Agent 运行时 → ruff 自动检查 + Agent 自修复
│ ├── 提交前 → pre-commit hook
│ ├── CI/CD → GitHub Actions
│ └── 启动时 → 四项健康检查
│
└── workspace/ ← Agent 工作区
十三、核心认知(续)
能力越强,Harness 越精密
Agent 只有计算器时,一个字符白名单就够了。有了文件读写,需要分级权限和备份。有了 Shell,需要四层防护叠加。有了网络访问,需要内容清洗和大小限制。每加一个工具,Harness 的复杂度都在增长。
架构决定了 Harness 的演进速度
800 行单文件 → 缩进 bug → 排查半小时。模块化 + 插件化之后 → 加新工具写一个文件 → 五分钟。架构不是锦上添花,它直接决定了你能多快地迭代 Harness。
安全控制要覆盖所有等价路径
Agent 是一个"会绕路的系统"——你堵住了 read_file 读 .env,它就用 cat .env;你堵住了 eval,它就写一个 Python 脚本来执行。每次加安全控制,都要问自己:还有没有其他路径能达到同样的效果?
质量左移不是替代右边,是叠加
Agent 运行时的 linter 不替代 CI/CD 里的 linter。它们是同一个检查在不同时间点各跑一次,覆盖不同的失败场景。左边的检查修复成本低,右边的检查是安全网。
pre-commit hook 的意外价值
它不只是一道检查——它是一个强制性的反馈信号。每次提交前被拦住,你都会意识到"原来我刚才的代码有问题"。这种即时反馈比 CI/CD 跑完后收到邮件有效得多。
十四、下一步
- 持续漂移检测:用 cron 定时跑健康检查,监控运行时指标
- Agent 自审代码:用 Agent 读自己的源码,找出问题并修复
- 部署到阿里云服务器:命令行 → API 服务,加多用户隔离
- Harness Templates:针对不同部署场景(本地 vs 云端)配置不同的 Harness 模板
本文所有代码均为作者亲手编写和调试。学习过程中与 Claude 协作,采用苏格拉底式教学——Claude 提问引导,作者思考并实现。