← 返回文章列表

从零构建 AI Agent (二):AI Agent 工程化进阶:从能用到好用的六次架构演进

上篇记录了从零构建 AI Agent 的完整过程,最终得到一个 800 行的模块化系统。但"能跑"和"好用"之间还有巨大的距离。本篇记录了接下来的六次架构演进:为最危险的 Shell 工具设计四层纵深防护、将 Harness 的 Sensor 部署到变更生命周期的四个阶段(质量左移)、堵住 Agent 通过多条路径读取敏感文件的漏洞、用装饰器实现工具插件化注册、用 pre/post execute 钩子将工具专属逻辑从 Agent Loop 中彻底解耦、以及给 Agent 加上安全的网络访问能力。贯穿始终的主线是一个正向循环:能力越强 → Harness 越精密 → 架构要先行 → 新 Harness 越容易加。

agent
harness

作者: 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 都能被自动覆盖。


五、工具插件化:从五处修改到一次注册

问题

每加一个新工具,需要改五个地方:

  1. tools.py:写函数实现
  2. tools.py:execute_tool 加 elif 分发
  3. tools.py:TOOL_DEFINITIONS 加描述 JSON
  4. config.py:ALLOWED_TOOLS 加名字
  5. 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 跑完后收到邮件有效得多。


十四、下一步

  1. 持续漂移检测:用 cron 定时跑健康检查,监控运行时指标
  2. Agent 自审代码:用 Agent 读自己的源码,找出问题并修复
  3. 部署到阿里云服务器:命令行 → API 服务,加多用户隔离
  4. Harness Templates:针对不同部署场景(本地 vs 云端)配置不同的 Harness 模板

本文所有代码均为作者亲手编写和调试。学习过程中与 Claude 协作,采用苏格拉底式教学——Claude 提问引导,作者思考并实现。

分享这篇文章

复制链接,或分享到你常用的地方。

评论

发表评论

0 / 1000

KEEP READING

全部文章