← 返回

Agent Loop 深度调研(三):从零构建生产级 Agent Loop

前两篇梳理了 Agent Loop 的核心概念和主流框架实现。这篇直接动手:从零构建一个可用于生产的 Agent Loop,逐步加入工具系统、上下文管理、权限控制、子代理等关键组件。

所有代码可直接运行,依赖 openaianthropic SDK 之一即可。

第一步:最小 Agent Loop

一个 Agent Loop 的最小可行实现只需要三样东西:消息列表、模型调用、工具执行。

import json
from openai import OpenAI

client = OpenAI()

# ---- 工具定义 ----
def calculator(expression: str) -> str:
    """计算数学表达式并返回结果"""
    try:
        result = eval(expression, {"__builtins__": {}})
        return str(result)
    except Exception as e:
        return f"计算错误: {e}"

def get_time() -> str:
    """返回当前时间"""
    from datetime import datetime
    return datetime.now().isoformat()

# 工具注册表:名称 → 函数
TOOL_REGISTRY = {
    "calculator": calculator,
    "get_time": get_time,
}

# OpenAI function calling schema
TOOLS_SCHEMA = [
    {
        "type": "function",
        "function": {
            "name": "calculator",
            "description": "计算数学表达式并返回结果",
            "parameters": {
                "type": "object",
                "properties": {
                    "expression": {
                        "type": "string",
                        "description": "要计算的数学表达式,如 '2 + 3 * 4'"
                    }
                },
                "required": ["expression"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "get_time",
            "description": "返回当前时间",
            "parameters": {"type": "object", "properties": {}}
        }
    }
]

# ---- Agent Loop 核心 ----
def agent_loop(user_message: str, max_turns: int = 10) -> str:
    """
    最小 Agent Loop 实现。
    
    循环逻辑:
    1. 发送消息给模型
    2. 如果模型请求工具调用 → 执行工具 → 结果回传 → 回到 1
    3. 如果模型返回纯文本 → 结束循环,返回结果
    """
    messages = [
        {
            "role": "system",
            "content": "你是一个有帮助的助手。使用工具来获取信息和执行计算。"
        },
        {"role": "user", "content": user_message}
    ]
    
    for turn in range(max_turns):
        print(f"\n--- Turn {turn + 1} ---")
        
        # 调用模型
        response = client.chat.completions.create(
            model="gpt-4o",
            messages=messages,
            tools=TOOLS_SCHEMA,
            tool_choice="auto",
        )
        
        message = response.choices[0].message
        
        # 终止条件:模型返回纯文本,无工具调用
        if not message.tool_calls:
            print(f"[Agent] {message.content}")
            return message.content
        
        # 将模型的 assistant 消息加入历史
        messages.append(message)
        
        # 执行所有工具调用
        for tool_call in message.tool_calls:
            func_name = tool_call.function.name
            func_args = json.loads(tool_call.function.arguments)
            
            print(f"[Tool Call] {func_name}({func_args})")
            
            # 从注册表查找并执行工具
            if func_name in TOOL_REGISTRY:
                result = TOOL_REGISTRY[func_name](**func_args)
            else:
                result = f"错误:未知工具 {func_name}"
            
            print(f"[Tool Result] {result}")
            
            # 将工具结果回传给模型
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": str(result),
            })
    
    raise RuntimeError(f"Agent Loop 在 {max_turns} 轮后仍未完成")

# ---- 运行 ----
if __name__ == "__main__":
    result = agent_loop("现在是几点?另外帮我算一下 (123 * 456) / 7 等于多少")
    print(f"\n最终结果: {result}")

这个最小循环已经包含了 Agent Loop 的全部核心逻辑:消息构建 → 模型调用 → 工具分发 → 结果回传 → 循环或终止。接下来的步骤都是围绕这个核心添加生产级基础设施。

第二步:添加工具系统

生产环境的工具系统需要解决三个问题:动态注册、错误隔离、安全沙箱。

import functools
import traceback
from dataclasses import dataclass, field
from typing import Callable, Any

@dataclass
class Tool:
    """工具定义"""
    name: str
    description: str
    parameters: dict  # JSON Schema
    fn: Callable
    requires_approval: bool = False  # 是否需要用户审批
    read_only: bool = True           # 是否只读操作

class ToolRegistry:
    """工具注册表"""
    
    def __init__(self):
        self._tools: dict[str, Tool] = {}
        self._schemas: list[dict] = []
    
    def register(
        self,
        description: str = "",
        parameters: dict | None = None,
        requires_approval: bool = False,
        read_only: bool = True,
    ):
        """装饰器:将函数注册为 Agent 工具"""
        def decorator(fn):
            name = fn.__name__
            tool = Tool(
                name=name,
                description=description or fn.__doc__ or "",
                parameters=parameters or {"type": "object", "properties": {}},
                fn=fn,
                requires_approval=requires_approval,
                read_only=read_only,
            )
            self._tools[name] = tool
            self._schemas.append({
                "type": "function",
                "function": {
                    "name": name,
                    "description": tool.description,
                    "parameters": tool.parameters,
                }
            })
            return fn
        return decorator
    
    def execute(self, name: str, args: dict) -> str:
        """安全执行工具,隔离错误"""
        if name not in self._tools:
            return json.dumps({"error": f"未知工具: {name}"})
        
        tool = self._tools[name]
        try:
            result = tool.fn(**args)
            return json.dumps({"result": result}, ensure_ascii=False)
        except TypeError as e:
            return json.dumps({"error": f"参数错误: {e}"})
        except Exception as e:
            # 错误隔离:工具崩溃不应终止 Agent Loop
            return json.dumps({
                "error": f"工具执行失败: {type(e).__name__}: {e}",
                "traceback": traceback.format_exc(),
            })
    
    def get_schemas(self) -> list[dict]:
        return self._schemas
    
    def requires_approval(self, name: str) -> bool:
        return self._tools.get(name, Tool("", "", {}, lambda: None)).requires_approval


# ---- 使用 ----
registry = ToolRegistry()

@registry.register(
    description="读取文件内容",
    parameters={
        "type": "object",
        "properties": {
            "path": {"type": "string", "description": "文件路径"}
        },
        "required": ["path"]
    },
    read_only=True,
)
def read_file(path: str) -> str:
    with open(path, 'r') as f:
        return f.read()

@registry.register(
    description="写入文件(需要审批)",
    parameters={
        "type": "object",
        "properties": {
            "path": {"type": "string", "description": "文件路径"},
            "content": {"type": "string", "description": "文件内容"},
        },
        "required": ["path", "content"]
    },
    requires_approval=True,
    read_only=False,
)
def write_file(path: str, content: str) -> str:
    with open(path, 'w') as f:
        f.write(content)
    return f"成功写入 {path},共 {len(content)} 字节"

@registry.register(
    description="执行 shell 命令(需要审批)",
    parameters={
        "type": "object",
        "properties": {
            "command": {"type": "string", "description": "要执行的命令"}
        },
        "required": ["command"]
    },
    requires_approval=True,
    read_only=False,
)
def run_shell(command: str) -> str:
    import subprocess
    result = subprocess.run(
        command, shell=True, capture_output=True, text=True, timeout=30
    )
    output = result.stdout + result.stderr
    return output[:5000]  # 截断过长输出

第三步:上下文压缩

上下文窗口是有限资源。参考 Claude Code 的五级策略,实现一个简化版的三级压缩。

from dataclasses import dataclass

@dataclass
class ContextManager:
    """上下文管理器:管理消息历史的 token 预算"""
    
    max_tokens: int = 100_000     # 上下文窗口上限
    reserve_tokens: int = 20_000  # 为模型输出预留的空间
    compaction_threshold: float = 0.7  # 使用率达到 70% 时触发压缩
    
    def estimate_tokens(self, messages: list[dict]) -> int:
        """粗略估算 token 数(1 token ≈ 4 字符 for English)"""
        total_chars = sum(len(json.dumps(m)) for m in messages)
        return total_chars // 4
    
    def should_compact(self, messages: list[dict]) -> bool:
        """判断是否需要压缩"""
        used = self.estimate_tokens(messages)
        budget = self.max_tokens - self.reserve_tokens
        return used / budget > self.compaction_threshold
    
    def compact(self, messages: list[dict], client: OpenAI) -> list[dict]:
        """
        三级压缩策略:
        Level 1: 截断工具输出(保留首尾各 500 字符)
        Level 2: 折叠早期对话(保留 system + 最近 N 轮)
        Level 3: 模型摘要(调用模型生成历史摘要)
        """
        budget = self.max_tokens - self.reserve_tokens
        
        # Level 1: 截断过长的工具结果
        messages = self._truncate_tool_outputs(messages, max_chars=1000)
        if self.estimate_tokens(messages) <= budget:
            return messages
        
        # Level 2: 保留 system + 最近消息,折叠中间部分
        system_msgs = [m for m in messages if m["role"] == "system"]
        non_system = [m for m in messages if m["role"] != "system"]
        
        # 保留最近 10 条消息
        keep_recent = 10
        if len(non_system) > keep_recent:
            old = non_system[:-keep_recent]
            recent = non_system[-keep_recent:]
            
            # Level 3: 调用模型摘要旧消息
            summary = self._summarize(client, old)
            
            return system_msgs + [
                {"role": "system", "content": f"[历史摘要]\n{summary}"}
            ] + recent
        
        return messages
    
    def _truncate_tool_outputs(
        self, messages: list[dict], max_chars: int = 1000
    ) -> list[dict]:
        """Level 1: 截断过长的工具输出"""
        result = []
        for msg in messages:
            if msg["role"] == "tool" and len(msg.get("content", "")) > max_chars:
                content = msg["content"]
                msg = {**msg, "content": (
                    content[:max_chars // 2]
                    + f"\n\n... [截断 {len(content) - max_chars} 字符] ...\n\n"
                    + content[-max_chars // 2:]
                )}
            result.append(msg)
        return result
    
    def _summarize(self, client: OpenAI, messages: list[dict]) -> str:
        """Level 3: 调用模型生成摘要"""
        conv_text = "\n".join(
            f"[{m['role']}] {json.dumps(m.get('content', ''), ensure_ascii=False)}"
            for m in messages
        )
        response = client.chat.completions.create(
            model="gpt-4o-mini",  # 用便宜模型做摘要
            messages=[{
                "role": "user",
                "content": (
                    "请为以下对话历史生成简洁摘要,保留关键决策、工具调用结果、"
                    "和当前任务状态。丢弃冗余的中间过程。\n\n"
                    f"{conv_text}"
                )
            }],
            max_tokens=2000,
        )
        return response.choices[0].message.content

第四步:权限控制系统

参考 Claude Code 的 deny-first 模型,实现一个基于规则的权限门控。

from enum import Enum
from dataclasses import dataclass

class PermissionMode(Enum):
    """权限模式"""
    DENY_ALL = "deny_all"         # 拒绝所有写操作
    ASK = "ask"                   # 每次询问用户
    ACCEPT_EDITS = "accept_edits" # 自动批准文件编辑,shell 仍需审批
    AUTO = "auto"                 # 自动批准所有操作

@dataclass
class PermissionGate:
    """权限门控"""
    
    mode: PermissionMode = PermissionMode.ASK
    
    # 危险模式列表(可配置)
    blocked_commands: list[str] = None  # 绝对禁止的命令
    
    def __post_init__(self):
        if self.blocked_commands is None:
            self.blocked_commands = [
                "rm -rf",
                "sudo",
                "chmod 777",
                ":(){",  # fork bomb
            ]
    
    async def check(
        self,
        tool_name: str,
        tool_args: dict,
        tool: Tool,
    ) -> tuple[bool, str]:
        """
        返回 (approved, reason)
        """
        # 硬编码黑名单:绝对拒绝
        if tool_name == "run_shell":
            cmd = tool_args.get("command", "")
            for blocked in self.blocked_commands:
                if blocked in cmd:
                    return False, f"命令被安全策略阻止: {blocked}"
        
        # 按模式处理
        if self.mode == PermissionMode.DENY_ALL:
            if not tool.read_only:
                return False, "当前模式禁止所有写操作"
            return True, ""
        
        if self.mode == PermissionMode.AUTO:
            return True, ""
        
        if self.mode == PermissionMode.ACCEPT_EDITS:
            if tool_name == "write_file":
                return True, ""
            if not tool.read_only:
                return await self._ask_user(tool_name, tool_args)
            return True, ""
        
        # ASK 模式:非只读操作需要审批
        if not tool.read_only:
            return await self._ask_user(tool_name, tool_args)
        
        return True, ""
    
    async def _ask_user(
        self, tool_name: str, tool_args: dict
    ) -> tuple[bool, str]:
        """
        在真实应用中,这里会弹出 UI 对话框或发送审批通知。
        这里简化为终端交互。
        """
        print(f"\n⚠️  工具 '{tool_name}' 需要审批")
        print(f"   参数: {json.dumps(tool_args, ensure_ascii=False, indent=2)}")
        
        answer = input("   批准执行?[y/n]: ").strip().lower()
        if answer in ("y", "yes"):
            return True, ""
        return False, "用户拒绝执行"

第五步:子代理委托

参考 Claude Code 的 sidechain 模式,子代理在隔离上下文中运行,只返回摘要。

@dataclass
class SubAgent:
    """子代理:在隔离上下文中执行特定任务"""
    
    name: str
    system_prompt: str
    tools: ToolRegistry
    max_turns: int = 5  # 子代理通常任务更聚焦
    
    async def run(self, task: str, client: OpenAI) -> str:
        """
        运行子代理,返回摘要结果。
        子代理的完整对话历史不会回传给主代理,
        只返回最终结果以保护主上下文窗口。
        """
        messages = [
            {"role": "system", "content": self.system_prompt},
            {"role": "user", "content": task},
        ]
        
        for turn in range(self.max_turns):
            response = client.chat.completions.create(
                model="gpt-4o",
                messages=messages,
                tools=self.tools.get_schemas(),
                tool_choice="auto",
            )
            
            message = response.choices[0].message
            
            if not message.tool_calls:
                # 子代理完成,返回最终结果
                return message.content
            
            messages.append(message)
            
            for tool_call in message.tool_calls:
                fn_name = tool_call.function.name
                fn_args = json.loads(tool_call.function.arguments)
                result = self.tools.execute(fn_name, fn_args)
                messages.append({
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "content": result,
                })
        
        return "子代理未在限定轮次内完成任务"

第六步:完整的 Agent Loop 组装

将以上所有组件组装为完整的生产级 Agent Loop。

import asyncio
import json
from dataclasses import dataclass, field

@dataclass
class AgentConfig:
    """Agent 配置"""
    model: str = "gpt-4o"
    system_prompt: str = "你是一个有帮助的 AI 助手。"
    max_turns: int = 20
    max_output_tokens: int = 4096
    permission_mode: PermissionMode = PermissionMode.ASK

@dataclass
class AgentHarness:
    """
    完整的 Agent Harness 实现。
    
    核心循环:
    1. 组装上下文
    2. 压缩(如需要)
    3. 调用模型
    4. 权限门控
    5. 执行工具
    6. 评估终止条件
    """
    
    config: AgentConfig
    tools: ToolRegistry
    context_mgr: ContextManager
    permission_gate: PermissionGate
    client: OpenAI = field(default_factory=OpenAI)
    
    # 子代理注册表
    sub_agents: dict[str, SubAgent] = field(default_factory=dict)
    
    # 执行日志(append-only)
    execution_log: list[dict] = field(default_factory=list)
    
    async def run(self, user_message: str) -> str:
        """执行完整的 Agent Loop"""
        
        messages = [
            {"role": "system", "content": self.config.system_prompt},
            {"role": "user", "content": user_message},
        ]
        
        turn = 0
        while turn < self.config.max_turns:
            turn += 1
            self._log("turn_start", {"turn": turn})
            
            # ---- 阶段 1: 上下文压缩 ----
            if self.context_mgr.should_compact(messages):
                original_count = len(messages)
                messages = self.context_mgr.compact(messages, self.client)
                self._log("compaction", {
                    "before": original_count,
                    "after": len(messages),
                })
            
            # ---- 阶段 2: 调用模型 ----
            try:
                response = self.client.chat.completions.create(
                    model=self.config.model,
                    messages=messages,
                    tools=self.tools.get_schemas(),
                    tool_choice="auto",
                    max_tokens=self.config.max_output_tokens,
                )
            except Exception as e:
                # 恢复策略:增大 max_tokens 重试
                self.config.max_output_tokens = min(
                    self.config.max_output_tokens * 2, 16384
                )
                self._log("model_error", {"error": str(e)})
                continue
            
            message = response.choices[0].message
            self._log("model_response", {
                "has_tool_calls": bool(message.tool_calls),
                "usage": response.usage.model_dump() if response.usage else None,
            })
            
            # ---- 阶段 3: 终止检查 ----
            if not message.tool_calls:
                self._log("complete", {"turns_used": turn})
                return message.content
            
            # ---- 阶段 4: 分派和执行工具 ----
            messages.append(message)
            
            for tool_call in message.tool_calls:
                fn_name = tool_call.function.name
                fn_args = json.loads(tool_call.function.arguments)
                
                self._log("tool_call", {"name": fn_name, "args": fn_args})
                
                # 检查是否是子代理委托
                if fn_name.startswith("delegate_to_"):
                    agent_name = fn_name.replace("delegate_to_", "")
                    if agent_name in self.sub_agents:
                        task = fn_args.get("task", "")
                        result = await self.sub_agents[agent_name].run(
                            task, self.client
                        )
                        messages.append({
                            "role": "tool",
                            "tool_call_id": tool_call.id,
                            "content": json.dumps({
                                "sub_agent": agent_name,
                                "result": result,
                            }),
                        })
                        continue
                
                # 权限门控
                tool = self.tools._tools.get(fn_name)
                if tool:
                    approved, reason = await self.permission_gate.check(
                        fn_name, fn_args, tool
                    )
                    if not approved:
                        messages.append({
                            "role": "tool",
                            "tool_call_id": tool_call.id,
                            "content": json.dumps({
                                "error": f"权限被拒绝: {reason}"
                            }),
                        })
                        self._log("permission_denied", {
                            "tool": fn_name, "reason": reason
                        })
                        continue
                
                # 执行工具(错误隔离)
                result = self.tools.execute(fn_name, fn_args)
                self._log("tool_result", {"name": fn_name, "result_len": len(result)})
                
                messages.append({
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "content": result,
                })
        
        # 超过最大轮次
        self._log("max_turns_exceeded", {"turns": turn})
        return "任务未能在限定轮次内完成。最后状态已保存。"
    
    def _log(self, event: str, data: dict):
        """Append-only 执行日志"""
        import time
        self.execution_log.append({
            "timestamp": time.time(),
            "event": event,
            **data,
        })
    
    def save_log(self, path: str):
        """持久化执行日志"""
        with open(path, 'w') as f:
            json.dump(self.execution_log, f, ensure_ascii=False, indent=2)


# =============================================
# 完整使用示例
# =============================================

async def main():
    # 1. 创建工具注册表
    tools = ToolRegistry()
    
    @tools.register(
        description="读取文件内容",
        parameters={
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "文件路径"}
            },
            "required": ["path"]
        },
        read_only=True,
    )
    def read_file(path: str) -> str:
        with open(path, 'r') as f:
            return f.read()
    
    @tools.register(
        description="创建或覆盖文件",
        parameters={
            "type": "object",
            "properties": {
                "path": {"type": "string"},
                "content": {"type": "string"},
            },
            "required": ["path", "content"]
        },
        requires_approval=True,
        read_only=False,
    )
    def write_file(path: str, content: str) -> str:
        with open(path, 'w') as f:
            f.write(content)
        return f"写入成功: {path}"
    
    @tools.register(
        description="执行 shell 命令",
        parameters={
            "type": "object",
            "properties": {
                "command": {"type": "string"}
            },
            "required": ["command"]
        },
        requires_approval=True,
        read_only=False,
    )
    def run_shell(command: str) -> str:
        import subprocess
        r = subprocess.run(
            command, shell=True, capture_output=True, text=True, timeout=30
        )
        return (r.stdout + r.stderr)[:5000]
    
    # 2. 创建子代理
    research_agent = SubAgent(
        name="researcher",
        system_prompt="你是一个研究员。分析代码库并给出结构化报告。",
        tools=tools,
        max_turns=5,
    )
    
    # 3. 注册子代理为工具
    @tools.register(
        description="委托研究任务给研究员子代理",
        parameters={
            "type": "object",
            "properties": {
                "task": {"type": "string", "description": "研究任务描述"}
            },
            "required": ["task"]
        },
        read_only=True,
    )
    def delegate_to_researcher(task: str) -> str:
        return ""  # 实际执行由 AgentHarness 拦截
    
    # 4. 组装 Harness
    harness = AgentHarness(
        config=AgentConfig(
            model="gpt-4o",
            system_prompt=(
                "你是一个高级软件工程助手。你可以读写文件、执行命令。\n"
                "对于复杂分析任务,使用 delegate_to_researcher 委托给研究员。\n"
                "始终先阅读文件再修改,执行命令前确认安全。"
            ),
            max_turns=20,
            permission_mode=PermissionMode.ASK,
        ),
        tools=tools,
        context_mgr=ContextManager(max_tokens=100_000),
        permission_gate=PermissionGate(mode=PermissionMode.ASK),
        sub_agents={"researcher": research_agent},
    )
    
    # 5. 运行
    result = await harness.run(
        "分析当前目录下的 Python 项目结构,找出所有超过 100 行的函数,"
        "并给出具体的重构建议。将结果保存到 refactor_suggestions.md"
    )
    
    print(f"\n{'='*60}")
    print(f"Agent 完成。结果:\n{result}")
    
    # 6. 保存执行日志
    harness.save_log("execution_log.json")
    print(f"\n执行日志已保存,共 {len(harness.execution_log)} 条记录")


if __name__ == "__main__":
    asyncio.run(main())

第七步:关键生产化检查清单

将自建 Agent Loop 投入生产前,逐项检查:

循环健壮性

  • 设置合理的 max_turns 防止无限循环(推荐 10-30)
  • 模型调用失败时有重试策略(指数退避,最多 3 次)
  • 工具执行超时保护(建议 30s 单次上限)
  • 工具错误隔离:单个工具崩溃不终止整个循环
  • 五个终止条件全部实现(无工具调用、最大轮次、上下文溢出、Hook 干预、外部中断)

上下文管理

  • 实现至少两级压缩(截断 + 摘要)
  • 工具输出截断:单次工具输出超过阈值时自动裁剪
  • 系统提示词与用户消息分离
  • Token 使用量实时监控

安全

  • 写操作默认需要审批(deny-first)
  • 危险命令硬编码黑名单
  • 工具参数校验(用 JSON Schema 或 Pydantic)
  • 沙箱执行环境(Docker / VM / 受限 shell)
  • 敏感信息过滤(API Key、密码等不进入消息历史)

可观测性

  • Append-only 执行日志(JSONL 格式)
  • 每轮记录:模型 token 使用、工具调用链、耗时、权限决策
  • 支持日志回放和调试
  • 异常检测和自动告警

多 Agent

  • 子代理使用隔离上下文(不共享主消息历史)
  • 子代理只返回摘要结果
  • 子代理有独立的 max_turns 限制(通常更小)
  • Handoff 场景需要明确的上下文交接协议

延伸阅读与参考实现

如果你想进一步深入研究或参考更成熟的实现,以下开源项目值得细看:

  • Dive-into-Claude-Code — 最详细的 Claude Code Agent Loop 逆向分析
  • deepclaude — 将 Agent Loop 架构移植到其他模型后端
  • statewright — 用状态机约束工具调用的护栏框架
  • OpenViking — 基于文件系统范式的上下文数据库
  • context-mode — 将大块数据隔离在活动窗口之外的沙箱工具
  • harness-experimental — 仓库级别的 Agent 运行脚手架
  • TaskWeaver — Microsoft 的 code-first 规划/执行分离框架
  • LLMLingua — Microsoft 的提示压缩工具包

官方文档: