释题:得心应手。当你掌握了自定义工具、Hooks 拦截、权限分层和流式会话这些高级能力之后,构建一个像自动化测试修复 Agent 这样的生产级应用,便如得心应手般自然流畅。
你好,我是黄佳。 上一讲我们学习了 Agent SDK 的基础用法,包括如何创建 Agent、发送查询、处理响应,以及单次调用模式的核心 API。有了这些基础,你已经可以让 Claude 在程序中跑起来了。但要在真实的工程环境中使用它,仅靠基础 API 还远远不够。你需要扩展 Agent 的能力边界,需要在关键节点插入安全控制,需要管理多轮交互的上下文状态,更需要一套完整的生产级运维策略。 这一讲,我们就来深入 Agent SDK 的高级特性。我会带你从自定义工具开始,逐步走过 Hooks 系统、四层权限管理、流式会话,最终完成一个完整的实战项目——自动化测试修复 Agent。这个 Agent 能自动运行测试、分析失败原因、提出修复方案,甚至在获得确认后自动修复代码。 一个中型电商项目在每次提交代码前,CI 会运行完整的测试套件,大约 200 个测试用例。大部分时候测试都能通过,但偶尔会有几个测试失败。问题是,测试失败的原因千奇百怪。有时是代码逻辑错误,有时是测试本身过时了,有时是环境配置问题,有时是 Mock 数据不对。 每次失败,我们都要重复后面的流程。
  • 阅读测试输出,找到失败的测试
  • 打开对应的测试文件,理解测试逻辑
  • 打开被测试的代码,分析失败原因
  • 决定是修复代码还是修复测试
  • 修改,重新运行,验证

这个过程短则十分钟,长则一小时。

那么能否构建一个测试修复 Agent,它能自动运行测试、分析失败原因、提出修复方案,甚至在获得确认后自动修复代码呢?这样一个曾经需要 30 分钟的修复工作,现在只需要 3 分钟的人工确认。

这一讲,我们就来构建这样一个 Agent。

在 Agent 中注入和使用自定义工具

Claude Agent SDK 内置了文件操作、命令执行、网络搜索等工具。但在实际项目中,你往往需要领域特定的能力:

  • 查询数据库
  • 调用内部 API
  • 发送通知
  • 执行特定的业务逻辑

这就是自定义工具的价值,让 Agent 能够调用你定义的函数。SDK 的自定义工具本质上是运行在你应用进程内的 MCP 服务器。与需要单独进程的常规 MCP 服务器不同,SDK 工具直接在你的 Python 应用中运行,消除了进程管理和 IPC 开销。这种设计让工具调用的延迟极低,同时还能共享应用的内存空间和数据库连接池等资源。

上图中的架构就是 Agent → MCP Server → Tools 的三层解耦调用链

左侧的 Agent(大模型 + 记忆 + 推理)并不直接调用具体工具,而是通过统一的 tool_use 请求,将意图表达为标准化的工具调用(如 mcp__{server}__{tool})。中间的 MCP Server 相当于一个“工具路由中枢”,负责根据命名规范解析请求、完成权限控制与路由分发,并调用对应的工具函数。

右侧的各类自定义工具只专注于执行具体能力(如查询、搜索、发送等),执行完成后将结果返回给 MCP Server,再统一回传给 Agent。通过标准命名 + 中间层路由,实现 Agent 与工具的解耦、可扩展和可治理,从而让系统可以像“插 USB 设备”一样动态接入新能力。

使用 @tool 装饰器定义工具

@tool 装饰器是定义自定义工具的最简单方式。你只需要指定工具名称、描述和参数,然后把业务逻辑写在函数体内。SDK 会自动将这个函数注册为一个可被 Agent 调用的工具,Agent 在推理过程中会根据工具描述决定何时调用它。

下面的例子定义了一个天气查询工具。注意返回值必须是包含 content 列表的字典,这是 MCP 协议要求的标准格式。

from claude_agent_sdk import tool

@tool(
    name="get_weather",
    description="Get current weather for a city",
    parameters={"city": str, "units": str}
)
async def get_weather(args):
    city = args["city"]
    units = args.get("units", "celsius")

    # 调用天气 API(示例)
    weather = await fetch_weather_api(city, units)

    return {
        "content": [
            {"type": "text", "text": f"Weather in {city}: {weather}"}
        ]
    }

下面是 @tool 装饰器的三个核心参数,每个参数都直接影响 Agent 的调用行为。

其中 description 尤为关键,它不是给人看的注释,而是给 AI 看的使用指南。写得清晰准确,Agent 才能在正确的时机调用正确的工具。

创建 SDK MCP 服务器承载工具

定义好工具函数之后,下一步是创建一个 MCP 服务器来承载它们。你可以把多个工具注册到同一个服务器中,服务器会统一管理这些工具的生命周期和调用路由。

下面的例子创建了一个包含两个工具的服务器。注意 @tool 装饰器的简写形式,当参数简单时,可以直接用位置参数传入名称、描述和参数字典。

from claude_agent_sdk import tool, create_sdk_mcp_server

@tool("greet", "Greet a user by name", {"name": str})
async def greet_user(args):
    return {
        "content": [
            {"type": "text", "text": f"Hello, {args['name']}!"}
        ]
    }

@tool("calculate", "Perform a calculation", {"expression": str})
async def calculate(args):
    try:
        result = eval(args["expression"])  # 生产环境请用安全的表达式解析器
        return {
            "content": [
                {"type": "text", "text": f"Result: {result}"}
            ]
        }
    except Exception as e:
        return {
            "content": [
                {"type": "text", "text": f"Error: {e}"}
            ],
            "isError": True
        }

# 创建 MCP 服务器
server = create_sdk_mcp_server(
    name="my-tools",
    version="1.0.0",
    tools=[greet_user, calculate]
)

服务器创建后,还不能直接使用。你需要把它注入到 Agent 的配置中,Agent 才能“看到”并调用这些工具。

注入并使用自定义工具

将 MCP 服务器注入 Agent 的方式很直观,通过 mcp_servers 选项传入服务器实例,然后在 allowed_tools 中声明允许使用的工具。工具名称遵循 mcp__{服务器名}__{工具名} 的命名格式,这个双下划线的命名规则确保了不同服务器之间的工具名不会冲突。

from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions

options = ClaudeAgentOptions(
    mcp_servers={"tools": server},
    # 工具名称格式:mcp__{服务器名}__{工具名}
    allowed_tools=[
        "mcp__tools__greet",
        "mcp__tools__calculate"
    ]
)

async with ClaudeSDKClient(options=options) as client:
    await client.query("Say hello to Alice and calculate 2 + 3 * 4")
    async for msg in client.receive_response():
        print(msg)

当 Agent 收到上面的提示时,它会自动识别出需要调用两个工具:先用 greet 向 Alice 打招呼,再用 calculate 计算表达式。这种自动编排能力正是 Agent SDK 的核心价值。

使用 Pydantic 进行参数验证

对于简单工具,字典式参数定义已经够用。但当参数变得复杂——比如有默认值、范围限制、可选字段时,Pydantic 模型是更好的选择。它不仅提供自动验证,还能生成更详细的 JSON Schema 供 Agent 参考,从而提高参数传递的准确性。

下面的例子定义了一个数据库查询工具。Pydantic 模型中的 Field 描述会被自动转换为工具参数说明,gele 约束则确保 Agent 传入的 limit 值在合理范围内。

from pydantic import BaseModel, Field
from claude_agent_sdk import tool

class DatabaseQueryParams(BaseModel):
    """数据库查询参数"""
    table: str = Field(..., description="Table name")
    columns: list[str] = Field(default=["*"], description="Columns to select")
    where: str | None = Field(default=None, description="WHERE clause")
    limit: int = Field(default=100, ge=1, le=1000, description="Max rows")

@tool(
    name="query_database",
    description="Execute a SELECT query on the database",
    parameters=DatabaseQueryParams
)
async def query_database(args: DatabaseQueryParams):
    # args 已经通过 Pydantic 验证
    query = f"SELECT {', '.join(args.columns)} FROM {args.table}"
    if args.where:
        query += f" WHERE {args.where}"
    query += f" LIMIT {args.limit}"

    # 执行查询
    results = await db.execute(query)

    return {
        "content": [
            {"type": "text", "text": f"Query: {query}\nResults: {results}"}
        ]
    }

下面是一个存在 SQL 注入风险的工具调用示例以及相应的调整。


## 危险:直接执行 SQL

@tool("run_sql", "Run any SQL", {"sql": str})

async def run_sql(args):

    return await db.execute(args["sql"])  # SQL 注入风险!


## 安全:限制操作类型

@tool("query_users", "Query user table", {"user_id": int})

async def query_users(args):

    return await db.execute(

        "SELECT * FROM users WHERE id = ?",

        [args["user_id"]]

    )

这个安全示例的核心在于, 不要把工具当“能力接口”,而要当“受控权限边界”来设计

危险版本把任意 SQL 执行权直接暴露给 Agent,相当于让一个不完全可信的系统拥有数据库 root 权限,一旦被误导或注入就可能造成严重破坏;而安全版本通过限制操作范围(只允许查询特定表)、使用参数化查询、防止注入,并对参数进行类型约束,把“无限能力”收敛为“可控动作”。本质上,这体现的是 Agent 系统的一个关键原则, 模型可以自由推理,但工具必须严格受限

Agent SDK Hooks 系统概述

Hooks 让你能够在 Agent 执行的各个阶段插入自定义逻辑。如果说自定义工具是扩展了 Agent 能做什么,那么 Hooks 就是控制 Agent 怎么做。它们提供对 Agent 行为的确定性控制——不是建议 Agent 遵守某个规则,而是在系统层面强制执行。

下表列出了 SDK 支持的所有 Hook 事件。每个事件对应 Agent 执行流程中的一个关键节点,你可以在这些节点插入安全检查、日志记录、数据转换等逻辑。

PreToolUse Hook:执行前拦截

PreToolUse 是最常用的 Hook,它在工具执行前触发。你可以在这里做三件事,允许执行、拒绝执行、或修改输入参数。这给了你对 Agent 行为的完全控制权。

下面的例子展示了一个 Bash 命令安全检查器。它会拦截所有 Bash 工具调用,检查命令是否包含危险模式(如 rm -rfsudo),如果发现危险则拒绝执行。对于不在白名单中的命令,它会要求用户手动确认。


from claude_agent_sdk import ClaudeAgentOptions, HookMatcher

async def check_bash_command(input_data, tool_use_id, context):

    """检查 Bash 命令是否安全"""

    tool_name = input_data["tool_name"]

    tool_input = input_data["tool_input"]

    if tool_name == "Bash":
        command = tool_input.get("command", "")

        # 阻止危险命令
        dangerous_patterns = ["rm -rf", "sudo", "chmod 777", "> /dev/"]
        for pattern in dangerous_patterns:
            if pattern in command:
                return {
                    "hookSpecificOutput": {
                        "hookEventName": "PreToolUse",
                        "permissionDecision": "deny",
                        "permissionDecisionReason": f"Blocked dangerous command: {pattern}"
                    }
                }

        # 只允许特定命令
        allowed_prefixes = ["npm", "python", "git", "pytest", "ls", "cat"]
        if not any(command.strip().startswith(p) for p in allowed_prefixes):
            return {
                "hookSpecificOutput": {
                    "hookEventName": "PreToolUse",
                    "permissionDecision": "ask",
                    "permissionDecisionReason": f"Command requires approval: {command}"
                }
            }

    return {}  # 允许执行

options = ClaudeAgentOptions(

    hooks={

        "PreToolUse": [

            HookMatcher(matcher="Bash", hooks=[check_bash_command])

        ]

    }

)

注意 HookMatchermatcher 参数,它指定这个 Hook 只对 Bash 工具生效。你也可以用 "*" 来匹配所有工具。

PreToolUse Hook:修改输入参数

从 Claude Code v2.0.10 开始,PreToolUse Hook 获得了一个强大的新能力——修改工具输入。这意味着你可以在工具执行前对参数进行转换、规范化或补充,而 Agent 对此完全无感知。

一个典型的应用场景是路径规范化。Agent 生成的文件路径有时是相对路径,但你的工具可能要求绝对路径。通过 PreToolUse Hook,你可以在调用发生前自动完成转换,避免工具报错。


async def normalize_file_paths(input_data, tool_use_id, context):

    """规范化文件路径"""

    tool_name = input_data["tool_name"]

    tool_input = input_data["tool_input"]

    if tool_name in ["Read", "Write", "Edit"]:
        file_path = tool_input.get("file_path", "")

        # 将相对路径转为绝对路径
        if not file_path.startswith("/"):
            import os
            absolute_path = os.path.abspath(file_path)

            return {
                "hookSpecificOutput": {
                    "hookEventName": "PreToolUse",
                    "permissionDecision": "allow",
                    "updatedInput": {
                        **tool_input,
                        "file_path": absolute_path
                    }
                }
            }

    return {}

options = ClaudeAgentOptions(

    hooks={

        "PreToolUse": [

            HookMatcher(matcher="*", hooks=[normalize_file_paths])

        ]

    }

)

返回值中的 updatedInput 字段就是修改后的工具输入。SDK 会用它替换原始输入,然后继续执行工具。

PostToolUse Hook:执行后处理

PostToolUse 在工具执行成功后触发,适合做日志记录、结果格式化、自动化后处理等工作。与 PreToolUse 不同,PostToolUse 无法改变已经发生的工具调用,但它可以基于调用结果执行额外操作。

下面展示了两个实用的 PostToolUse Hook。第一个记录所有工具的使用日志,用于审计和调试。第二个在文件写入后自动运行代码格式化工具,确保 Agent 生成的代码符合团队代码风格规范。


import logging

from datetime import datetime

logger = logging.getLogger(__name__)

async def log_tool_usage(input_data, tool_use_id, context):

    """记录工具使用日志"""

    tool_name = input_data["tool_name"]

    tool_input = input_data.get("tool_input", {})

    tool_response = input_data.get("tool_response", {})

    logger.info(f"[{datetime.now().isoformat()}] Tool: {tool_name}")
    logger.info(f"  Input: {tool_input}")
    logger.info(f"  Response: {str(tool_response)[:200]}...")

    return {}

async def auto_format_code(input_data, tool_use_id, context):

    """文件写入后自动格式化"""

    tool_name = input_data["tool_name"]

    tool_input = input_data.get("tool_input", {})

    if tool_name in ["Write", "Edit"]:
        file_path = tool_input.get("file_path", "")

        # 根据文件类型运行格式化
        if file_path.endswith(".py"):
            import subprocess
            subprocess.run(["black", file_path], capture_output=True)
        elif file_path.endswith((".ts", ".js")):
            import subprocess
            subprocess.run(["prettier", "--write", file_path], capture_output=True)

    return {}

options = ClaudeAgentOptions(

    hooks={

        "PostToolUse": [

            HookMatcher(matcher="*", hooks=[log_tool_usage]),

            HookMatcher(matcher="Write", hooks=[auto_format_code]),

            HookMatcher(matcher="Edit", hooks=[auto_format_code])

        ]

    }

)

自动格式化这个 Hook 特别实用。Agent 生成的代码虽然逻辑正确,但缩进、换行、引号风格可能不符合项目规范。有了这个 Hook,你再也不需要手动跑格式化了。

canUseTool 回调:运行时权限控制

除了 Hooks,SDK 还提供了 canUseTool 回调作为另一种权限控制方式。它比 Hooks 更简单,只负责回答一个问题:“这个工具调用是否被允许?”不涉及输入修改、日志记录等复杂逻辑,适合纯粹的权限判断场景。

下面的例子展示了一个保护敏感文件和限制网络操作的 canUseTool 回调。当 Agent 试图读写受保护的文件或执行网络命令时,回调会返回拒绝并附带原因说明。

# 受保护的文件列表

PROTECTED_FILES = [

    ".env",

    "secrets.json",

    "config/production.yaml",

    "database/migrations/"

]

async def can_use_tool(tool_name: str, tool_input: dict) -> dict:

    """运行时权限检查"""

    # 检查文件操作
    if tool_name in ["Write", "Edit", "Read"]:
        file_path = tool_input.get("file_path", "")

        for protected in PROTECTED_FILES:
            if protected in file_path:
                return {
                    "allowed": False,
                    "reason": f"Access to {protected} is not allowed"
                }

    # 检查 Bash 命令
    if tool_name == "Bash":
        command = tool_input.get("command", "")

        # 禁止网络操作
        network_commands = ["curl", "wget", "nc", "ssh"]
        for cmd in network_commands:
            if cmd in command:
                return {
                    "allowed": False,
                    "reason": f"Network command '{cmd}' is not allowed"
                }

    return {"allowed": True}

options = ClaudeAgentOptions(

    can_use_tool=can_use_tool

)

Hooks 与 canUseTool 的选择

Hooks 和 canUseTool 都能控制工具的使用权限,但它们的能力范围差异很大。理解这个差异对于选择合适的机制至关重要。

简单来说,只需要权限检查,用 canUseTool;需要修改输入、记录日志、执行后处理,用 Hooks。在实际项目中,两者经常配合使用,canUseTool 负责快速的权限判断,Hooks 负责更复杂的拦截和处理逻辑。

Agent SDK 权限管理:四道防线

安全是构建生产级 Agent 的核心议题。Agent SDK 提供了四种互补的权限控制机制, 权限模式、canUseTool 回调、Hooks、settings.json 中的权限规则。 它们构成了一个分层防御体系。

让我逐一介绍这四道防线。

权限模式:全局基调

权限模式是最粗粒度的控制,它设定了整个会话的安全基调。一共有四种模式可选,从宽松到严格,你需要根据使用场景选择合适的模式。


options = ClaudeAgentOptions(

    permission_mode="acceptEdits"  # 自动接受文件编辑

)

工具白名单与黑名单

第二道防线是工具级别的准入控制。通过 allowed_toolsdisallowed_tools,你可以精确控制 Agent 能使用哪些工具。这比权限模式更细粒度,你可以允许文件读取但禁止网络搜索,或者只允许运行特定的 Bash 命令。


options = ClaudeAgentOptions(

    # 只允许这些工具

    allowed_tools=["Read", "Grep", "Glob", "Bash(pytest:*)"],

    # 禁用这些工具
    disallowed_tools=["Task", "WebSearch"]

)

注意 Bash(pytest:*) 这个语法,它表示只允许以 pytest 开头的 Bash 命令。这种细粒度的 Bash 命令过滤是生产环境中非常实用的安全特性。

第三道防线是运行时 动态权限检查( canUseTool ,第四道防线是最 细粒度的 Hooks 控制 。它们的工作原理在前面已经详细讲解过,这里不再赘述。

在实际项目中,这四道防线应该配合使用,形成纵深防御。下面的代码展示了一个完整的四层安全配置。请注意每一层防线各司其职:权限模式设定基调,白名单限制工具集,canUseTool 保护敏感资源,Hooks 提供细粒度控制和审计。


options = ClaudeAgentOptions(

    # 第一道:权限模式

    permission_mode="acceptEdits",

    # 第二道:工具白名单
    allowed_tools=["Read", "Write", "Edit", "Bash", "Grep", "Glob"],
    disallowed_tools=["WebSearch"],  # 禁止网络搜索

    # 第三道:运行时检查
    can_use_tool=can_use_tool,

    # 第四道:Hooks
    hooks={
        "PreToolUse": [
            HookMatcher(matcher="Bash", hooks=[check_bash_command]),
            HookMatcher(matcher="*", hooks=[log_all_tools])
        ],
        "PostToolUse": [
            HookMatcher(matcher="Write", hooks=[auto_format])
        ]
    }

)

流式会话:为什么以及怎么用

到目前为止,我们的示例都使用的是单次查询模式——发送一个请求,接收一个响应。但在生产环境中,你往往需要多轮对话、中途干预、动态调整参数。这就是流式会话(Streaming Session)的价值。流式输入模式是使用 Claude Agent SDK 的首选方式。它允许 Agent 作为长时间运行的进程,接收用户输入、处理中断、显示权限请求、管理会话。

下表清晰展示了两种模式的差异。

流式会话的核心优势是保持上下文。在同一个 async with 块内,你可以发送多次查询,每次查询都能“看到”之前的对话历史。这让 Agent 能够执行复杂的多步骤任务,而不需要你手动管理上下文。


from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions

async def streaming_session():

    options = ClaudeAgentOptions(

        allowed_tools=["Read", "Write", "Bash"],

        permission_mode="default"

    )

    async with ClaudeSDKClient(options=options) as client:
        # 第一轮对话
        await client.query("列出当前目录的 Python 文件")
        async for msg in client.receive_response():
            if msg.type == "text":
                print(msg.text)

        # 继续对话(保持上下文)
        await client.query("分析第一个文件的代码质量")
        async for msg in client.receive_response():
            if msg.type == "text":
                print(msg.text)

        # 再次继续
        await client.query("修复发现的问题")
        async for msg in client.receive_response():
            print(msg)

这三轮对话共享同一个会话上下文。Agent 在第二轮能引用第一轮列出的文件,在第三轮能基于第二轮的分析结果执行修复。

处理权限请求

在流式模式中,当 Agent 试图执行需要权限的操作时,SDK 不会自动处理,而是将权限请求发送给你的代码。你可以根据工具类型、命令内容等信息做出自动决策,也可以将决策权交给用户。

下面的例子展示了一种混合策略:对于测试命令自动批准,对于其他命令则询问用户。


async def handle_permission_request(request):

    """处理权限请求"""

    tool_name = request.get("tool_name")

    tool_input = request.get("tool_input")

    print(f"\nPermission Request:")
    print(f"   Tool: {tool_name}")
    print(f"   Input: {tool_input}")

    # 自动决策或询问用户
    if tool_name == "Bash":
        command = tool_input.get("command", "")
        if command.startswith("npm test") or command.startswith("pytest"):
            return {"approved": True}

    # 询问用户
    response = input("   Approve? (y/n): ")
    return {"approved": response.lower() == "y"}

async with ClaudeSDKClient(options=options) as client:

    await client.query("运行测试并修复失败的测试")

    async for msg in client.receive_response():
        if msg.type == "permission_request":
            decision = await handle_permission_request(msg)
            await client.respond_to_permission(msg.id, decision)
        else:
            print(msg)

中断和取消

流式会话支持在任意时刻中断 Agent 的执行。这在 Agent 陷入无意义循环、执行时间过长、或用户改变主意时非常有用。调用 client.interrupt() 后,Agent 会停止当前操作,但会话上下文仍然保留,你可以继续发送新的查询。


import asyncio

async def interruptible_session():

    async with ClaudeSDKClient(options=options) as client:

        await client.query("分析整个代码库")

        try:
            async for msg in client.receive_response():
                print(msg)

                # 检查是否需要中断
                if should_interrupt():
                    await client.interrupt()
                    print("Task interrupted by user")
                    break

        except asyncio.CancelledError:
            print("Session cancelled")

动态切换设置

流式模式还有一个独特的能力:在会话中途动态切换设置。最典型的场景是“先分析后执行”模式,先用只读模式让 Agent 分析问题并制定计划,用户确认后再切换到可编辑模式执行修改。这种两阶段工作流在生产环境中非常常见,它既保证了安全性,又保持了效率。


async with ClaudeSDKClient(options=options) as client:

    # 开始时使用只读模式

    await client.update_options(permission_mode="planMode")

    await client.query("分析代码并制定修复计划")

    async for msg in client.receive_response():

        print(msg)

    # 用户确认后,切换到可编辑模式
    await client.update_options(permission_mode="acceptEdits")
    await client.query("执行刚才的修复计划")
    async for msg in client.receive_response():
        print(msg)

实战项目:自动化测试修复 Agent

现在,让我们把前面学到的所有高级特性组合起来,构建开篇故事中的测试修复 Agent。这个项目会用到自定义工具(运行测试)、Hooks(安全控制)、流式会话(两阶段工作流)和四层权限管理。

这个项目的项目需求是构建一个 Agent来完成下面的任务。

  • 运行测试套件,捕获失败信息
  • 分析失败原因
  • 提出修复方案
  • 在确认后执行修复
  • 重新运行测试验证

自定义工具:测试运行器

首先,我们需要一个能够运行测试并返回结构化结果的自定义工具。这个工具会调用 pytest,解析 JSON 报告,提取失败测试的详细信息(测试名称、错误信息),然后以标准 MCP 格式返回给 Agent。

Agent 拿到这些结构化数据后,就能精确定位需要分析的文件和代码行。

我们还额外定义了一个 get_test_history 工具,用于查询最近的测试运行历史。这能帮助 Agent 判断测试失败是偶发性的还是持续性的,从而做出更准确的修复决策。


from claude_agent_sdk import tool, create_sdk_mcp_server

import subprocess

import json

@tool(

    name="run_tests",

    description="Run the test suite and return results",

    parameters={

        "test_path": str,  # 可选:指定测试路径

        "verbose": bool    # 可选:详细输出

    }

)

async def run_tests(args):

    """运行 pytest 测试"""

    test_path = args.get("test_path", "tests/")

    verbose = args.get("verbose", False)

    cmd = ["pytest", test_path, "--tb=short", "-q"]
    if verbose:
        cmd.append("-v")

    # 添加 JSON 输出
    cmd.extend(["--json-report", "--json-report-file=test-results.json"])

    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=300  # 5 分钟超时
        )

        # 读取 JSON 报告
        try:
            with open("test-results.json") as f:
                report = json.load(f)
        except:
            report = None

        output = {
            "stdout": result.stdout,
            "stderr": result.stderr,
            "return_code": result.returncode,
            "success": result.returncode == 0
        }

        if report:
            output["summary"] = {
                "total": report.get("summary", {}).get("total", 0),
                "passed": report.get("summary", {}).get("passed", 0),
                "failed": report.get("summary", {}).get("failed", 0),
                "errors": report.get("summary", {}).get("errors", 0)
            }
            output["failed_tests"] = [
                {
                    "name": t["nodeid"],
                    "message": t.get("call", {}).get("longrepr", "")
                }
                for t in report.get("tests", [])
                if t.get("outcome") == "failed"
            ]

        return {
            "content": [
                {"type": "text", "text": json.dumps(output, indent=2)}
            ]
        }

    except subprocess.TimeoutExpired:
        return {
            "content": [
                {"type": "text", "text": "Error: Test execution timed out after 5 minutes"}
            ],
            "isError": True
        }
    except Exception as e:
        return {
            "content": [
                {"type": "text", "text": f"Error running tests: {e}"}
            ],
            "isError": True
        }

@tool(

    name="get_test_history",

    description="Get recent test run history",

    parameters={"limit": int}

)

async def get_test_history(args):

    """获取测试历史(示例实现)"""

    limit = args.get("limit", 5)

    # 实际实现中,这里会从数据库或日志读取
    history = [
        {"timestamp": "2025-01-18 10:00", "passed": 198, "failed": 2},
        {"timestamp": "2025-01-18 09:30", "passed": 200, "failed": 0},
        {"timestamp": "2025-01-18 09:00", "passed": 195, "failed": 5}
    ][:limit]

    return {
        "content": [
            {"type": "text", "text": json.dumps(history, indent=2)}
        ]
    }

# 创建测试工具服务器

test_tools_server = create_sdk_mcp_server(

    name="test-tools",

    version="1.0.0",

    tools=[run_tests, get_test_history]

)

Hooks 配置:安全控制

测试修复 Agent 需要修改源代码文件,这是一个高风险操作。我们通过 Hooks 实现两个关键的安全控制:第一,限制 Agent 只能修改 tests/src/lib/ 目录下的文件,禁止修改 setup.pypyproject.toml 等项目配置文件;第二,记录所有文件修改操作到日志文件,便于事后审计和回滚。

# 允许修改的文件模式

ALLOWED_EDIT_PATTERNS = [

    "tests/",

    "src/",

    "lib/"

]

# 禁止修改的文件

FORBIDDEN_FILES = [

    "setup.py",

    "pyproject.toml",

    "requirements.txt",

    ".github/",

    "conftest.py"

]

async def check_file_modification(input_data, tool_use_id, context):

    """检查文件修改权限"""

    tool_name = input_data["tool_name"]

    tool_input = input_data["tool_input"]

    if tool_name in ["Write", "Edit"]:
        file_path = tool_input.get("file_path", "")

        # 检查禁止列表
        for forbidden in FORBIDDEN_FILES:
            if forbidden in file_path:
                return {
                    "hookSpecificOutput": {
                        "hookEventName": "PreToolUse",
                        "permissionDecision": "deny",
                        "permissionDecisionReason": f"Modification of {forbidden} is not allowed"
                    }
                }

        # 检查允许列表
        allowed = any(file_path.startswith(p) for p in ALLOWED_EDIT_PATTERNS)
        if not allowed:
            return {
                "hookSpecificOutput": {
                    "hookEventName": "PreToolUse",
                    "permissionDecision": "ask",
                    "permissionDecisionReason": f"File {file_path} is outside allowed directories"
                }
            }

    return {}

async def log_modifications(input_data, tool_use_id, context):

    """记录所有修改"""

    tool_name = input_data["tool_name"]

    tool_input = input_data["tool_input"]

    if tool_name in ["Write", "Edit"]:
        file_path = tool_input.get("file_path", "")

        # 记录到修改日志
        with open("modification-log.txt", "a") as f:
            from datetime import datetime
            f.write(f"[{datetime.now().isoformat()}] {tool_name}: {file_path}\n")

    return {}

这两个 Hook 分别挂载在 PreToolUsePostToolUse 事件上,前者在文件修改前做准入检查,后者在修改成功后记录审计日志。

下面是完整的测试修复 Agent 代码。它综合运用了自定义工具、Hooks、流式会话和动态权限切换,实现了“先分析后修复”的两阶段工作流。第一阶段使用 default 权限模式,Agent 只分析不修改;用户确认修复方案后,切换到 acceptEdits 模式执行修复。


#!/usr/bin/env python3

"""

自动化测试修复 Agent

运行测试、分析失败、修复代码、验证修复。

"""

import asyncio

from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions, HookMatcher

# 导入自定义工具和 Hooks(见上文定义)
# from tools import test_tools_server
# from hooks import check_file_modification, log_modifications

async def run_test_fixer():

    """运行测试修复 Agent"""

    # 配置选项
    options = ClaudeAgentOptions(
        # 模型选择
        model="sonnet",

        # MCP 服务器
        mcp_servers={"test-tools": test_tools_server},

        # 允许的工具
        allowed_tools=[
            "Read",
            "Write",
            "Edit",
            "Grep",
            "Glob",
            "Bash(pytest:*)",  # 只允许 pytest 命令
            "mcp__test-tools__run_tests",
            "mcp__test-tools__get_test_history"
        ],

        # 权限模式:先分析,确认后再修改
        permission_mode="default",

        # 最大轮次
        max_turns=30,

        # Hooks
        hooks={
            "PreToolUse": [
                HookMatcher(matcher="Write", hooks=[check_file_modification]),
                HookMatcher(matcher="Edit", hooks=[check_file_modification])
            ],
            "PostToolUse": [
                HookMatcher(matcher="Write", hooks=[log_modifications]),
                HookMatcher(matcher="Edit", hooks=[log_modifications])
            ]
        }
    )

    # 系统提示
    system_prompt = """你是一个专业的测试修复助手。你的任务是:

1.运行测试套件,识别失败的测试
2.分析每个失败测试的原因
3.确定是代码 bug 还是测试本身的问题
4.提出具体的修复方案
5.在获得确认后执行修复
6.重新运行测试验证修复

修复原则:

- 最小化修改:只改必要的代码
- 优先修复代码:除非测试本身有问题
- 保持测试覆盖:不要删除测试来"修复"问题
- 记录修改:说明每个修改的原因

输出格式:

- 先运行测试,报告结果
- 对每个失败的测试,分析原因
- 提出修复方案,等待确认
- 执行修复后,重新验证
"""

    async with ClaudeSDKClient(options=options) as client:

        print("Test Fixer Agent Started")

        print("=" * 50)

        # 第一阶段:运行测试并分析
        print("\nPhase 1: Running tests and analyzing failures...")

        await client.query(f"""{system_prompt}

请开始:

1. 首先运行测试套件
2. 分析所有失败的测试
3. 为每个失败提出修复方案

注意:在这个阶段只分析,不要修改任何文件。

""")

        analysis_result = []
        async for msg in client.receive_response():
            if msg.type == "text":
                print(msg.text)
                analysis_result.append(msg.text)
            elif msg.type == "tool_use":
                print(f"  [Tool] {msg.tool_name}...")

        # 等待用户确认
        print("\n" + "=" * 50)
        print("Analysis complete. Review the proposed fixes above.")
        confirm = input("Proceed with fixes? (y/n): ")

        if confirm.lower() != "y":
            print("Aborted by user")
            return

        # 第二阶段:执行修复
        print("\nPhase 2: Applying fixes...")

        # 切换到接受编辑模式
        await client.update_options(permission_mode="acceptEdits")

        await client.query("""

现在请执行你提出的修复方案。

修复完成后,重新运行测试验证。

""")

        async for msg in client.receive_response():
            if msg.type == "text":
                print(msg.text)
            elif msg.type == "tool_use":
                print(f"  [Tool] {msg.tool_name}: {msg.tool_input.get('file_path', msg.tool_input.get('command', ''))}")
            elif msg.type == "result":
                print(f"\nCompleted in {msg.duration_ms/1000:.1f}s")
                print(f"   Cost: ${msg.total_cost_usd:.4f}")
                print(f"   Turns: {msg.num_turns}")

if __name__ == "__main__":

    asyncio.run(run_test_fixer())

执行测试修复 Agent ,可以看到它自动完成了从运行测试、分析失败、到修复代码、验证结果的完整流程。整个过程中,Agent 精准识别了两个失败测试的根因,一个是模型默认值变更,一个是 API 路径更新,并提出了合理的修复方案。


$ python test_fixer.py

Test Fixer Agent Started
==================================================

Phase 1: Running tests and analyzing failures...

  [Tool] mcp__test-tools__run_tests...

Test Results:

- Total: 200
- Passed: 198
- Failed: 2

Failed Tests Analysis:

1. tests/test_user.py::test_user_creation
   Error: AssertionError: expected 'active' but got 'pending'
   Analysis: The User model's default status was changed from 'active' to 'pending'
             in commit abc123, but the test wasn't updated.
   Proposed Fix: Update the test to expect 'pending' status, OR restore the
                 default to 'active' if that was unintentional.

2. tests/test_api.py::test_get_user_endpoint
   Error: 404 Not Found
   Analysis: The endpoint path was changed from /api/user to /api/users (plural)
             but the test still uses the old path.
   Proposed Fix: Update the test to use /api/users

==================================================

Analysis complete. Review the proposed fixes above.

Proceed with fixes? (y/n): y

Phase 2: Applying fixes...

  [Tool] Edit: tests/test_user.py

  [Tool] Edit: tests/test_api.py

  [Tool] mcp__test-tools__run_tests...

All tests passed! (200/200)

Completed in 45.3s

   Cost: $0.0821

   Turns: 12

生产环境最佳实践

本课的最后,我们来介绍一系列的生产环境中应用SDK的的最佳实践。

成本控制

将 Agent 部署到生产环境时,成本控制是第一个需要关注的问题。Agent 的每一轮工具调用都会消耗 token,而不受控的 Agent 可能在一次任务中消耗大量 API 额度。以下策略能帮助你有效控制成本:选择合适的模型(简单任务用 Haiku 而非 Sonnet)、限制最大轮次、限制工具集(减少不必要的操作),以及在运行时监控累计成本。


from claude_agent_sdk import ClaudeAgentOptions

options = ClaudeAgentOptions(

    # 使用更便宜的模型处理简单任务

    model="haiku",

    # 限制轮次
    max_turns=20,

    # 限制工具(减少不必要的操作)
    allowed_tools=["Read", "Grep", "Glob"],  # 只读

)

# 监控成本

async for msg in client.receive_response():

    if msg.type == "result":

        if msg.total_cost_usd > 0.50:

            logger.warning(f"High cost query: ${msg.total_cost_usd}")

错误重试

网络波动、API 限流、临时性服务中断——这些问题在生产环境中不可避免。一个健壮的 Agent 应用需要内置重试机制。下面的实现使用指数退避策略:第一次失败后等 1 秒重试,第二次等 2 秒,第三次等 4 秒。这种策略既避免了对 API 的过度请求,又在大多数临时性故障中能自动恢复。


import asyncio

from claude_agent_sdk import ClaudeAgentError

async def resilient_query(client, prompt, max_retries=3):

    """带重试的查询"""

    for attempt in range(max_retries):

        try:

            await client.query(prompt)

            results = []

            async for msg in client.receive_response():

                results.append(msg)

                if msg.type == "error":

                    raise ClaudeAgentError(msg.error)

            return results

        except ClaudeAgentError as e:
            if attempt < max_retries - 1:
                wait_time = 2 ** attempt  # 指数退避
                logger.warning(f"Attempt {attempt + 1} failed, retrying in {wait_time}s...")
                await asyncio.sleep(wait_time)
            else:
                raise

超时处理

Agent 任务可能因为各种原因卡住,等待一个永远不会返回的 API 调用,或者陷入无意义的推理循环。设置合理的超时时间是防止资源浪费的重要手段。Python 3.11 引入的 asyncio.timeout 上下文管理器,让超时处理变得非常优雅。


import asyncio

async def query_with_timeout(client, prompt, timeout=300):

    """带超时的查询"""

    try:

        await client.query(prompt)

        async with asyncio.timeout(timeout):
            results = []
            async for msg in client.receive_response():
                results.append(msg)
            return results

    except asyncio.TimeoutError:
        await client.interrupt()
        logger.error(f"Query timed out after {timeout}s")
        raise

审计日志

在企业环境中,所有 Agent 操作都应该被记录下来。审计日志不仅用于调试,更是合规要求。下面的 AuditLogger 以 JSONL 格式(每行一个 JSON 对象)记录所有工具调用,包括时间戳、工具名称、输入参数和调用 ID。这种格式便于后续用 ELK Stack 或 Splunk 等日志分析工具处理。


import json

from datetime import datetime

class AuditLogger:

    def __init__(self, log_file="agent-audit.jsonl"):

        self.log_file = log_file

    def log(self, event_type, data):
        entry = {
            "timestamp": datetime.now().isoformat(),
            "event": event_type,
            "data": data
        }
        with open(self.log_file, "a") as f:
            f.write(json.dumps(entry) + "\n")

audit = AuditLogger()

async def audited_tool_usage(input_data, tool_use_id, context):

    """审计所有工具使用"""

    audit.log("tool_use", {

        "tool": input_data["tool_name"],

        "input": input_data["tool_input"],

        "tool_use_id": tool_use_id

    })

    return {}

options = ClaudeAgentOptions(

    hooks={

        "PreToolUse": [

            HookMatcher(matcher="*", hooks=[audited_tool_usage])

        ]

    }

)

总结一下

这一讲我们深入学习了 Claude Agent SDK 的高级特性,并构建了一个完整的生产级 Agent。

自定义工具让 Agent 能够调用你定义的函数。使用 @tool 装饰器定义工具,使用 create_sdk_mcp_server 创建承载工具的 MCP 服务器,然后通过 mcp_servers 选项注入到 Agent 中。工具命名遵循 mcp__{服务器名}__{工具名} 格式。设计工具时要遵循单一职责、清晰描述、安全优先的原则。

Hooks 系统让你能够在 Agent 执行的各个阶段插入自定义逻辑。PreToolUse 在工具执行前触发,可以允许、拒绝或修改工具输入;PostToolUse 在工具执行后触发,适合日志记录和后处理。canUseTool 是另一种权限控制方式,更简单但功能有限。

权限管理是构建安全 Agent 的关键。SDK 提供四道防线:权限模式(全局设置)、工具白名单/黑名单(工具级别)、canUseTool 回调(运行时检查)、Hooks(最细粒度控制)。这四道防线应该配合使用,形成分层防御。

流式会话是生产级应用的首选模式。它支持多轮对话、中断执行、动态切换权限、自定义权限请求处理。相比单次 query() 调用,流式会话提供了更丰富的交互能力和更精细的控制。

实战项目展示了如何将这些特性组合起来。测试修复 Agent 使用自定义工具运行测试、使用 Hooks 控制文件修改权限、使用流式会话实现两阶段工作流(先分析后修复)。这个模式可以推广到许多类似场景,代码审查、文档生成、数据处理等。

下面,我想送给大家一份生产级 Agent 上线清单,把 Agent 部署到生产环境前,你可以过一遍这个清单。

希望大家利用好Agent SDK,设计出功能强大,可用而又可靠的Agent系统!

思考题

  • 如果你要构建一个代码重构 Agent,你会定义哪些自定义工具?如何设计安全策略?
  • PreToolUse Hook 可以修改工具输入。这个能力有什么应用场景?有什么风险?
  • 流式会话允许中途切换权限模式。你能想到什么场景需要这个能力?
  • 本文中我们列出了哪些生产环境最佳实践,除此之外你还能想到哪些重要的实践方法和注意事项。

下一讲预告

到这里,我们已经学习了 Claude Code 的所有核心能力:记忆系统、子代理、Skills、命令、Hooks、MCP、Headless 模式、Agent SDK。每一种能力都是独立的积木块,但真正的工程价值在于把它们组合成可复用、可分享的整体。

下一讲,我们将学习 Plugins 插件打包与分发——把这些能力组合成可复用的插件,实现团队资产沉淀与共享。你将学会插件的目录结构和 manifest 文件、如何打包 Commands、Skills、Agents、Hooks,以及插件的发布和安装流程。我们还会动手构建一个完整的”团队能力包”,把前面所学的一切打包成一个开箱即用的插件。

欢迎你在留言区参与讨论,如果这节课对你有启发,别忘了分享给身边更多朋友。