Skip to content

原文:https://www.mstx.cn/harness/418.html · 创建:2026-09-04 · 更新:2026-09-04 · 阅读:1192


从本节开始,你不再只是"写代码" ​

在1.5节中,你写了一个能跑的Agent。那段代码的核心是一个while循环,加上几次OpenAI API调用。它确实能工作,但它是不可复用的、不可测试的、不可观测的——它只是一段脚本。

从本节开始,你要做一件不同的事情:把那段脚本改造成一个最小但完整的Harness。

什么是最小Harness?它至少具备以下特征:

  • 有清晰的模块边界(Runtime、Tool Registry、Memory),而非一堆散落的代码
  • 有基本的错误处理和终止条件管理
  • 有执行轨迹的记录(哪怕只是结构化日志)
  • 可以通过配置而非修改代码来调整行为

本节的目标不是构建一个功能完备的Harness,而是搭建一个正确的骨架。这个骨架在后续章节中会逐步长出血肉——工具系统、记忆管理、评估体系、可观测性——但骨架的结构从第一天起就应该是合理的。

项目结构 ​

先创建项目目录:

my-harness/
├── harness/
│   ├── __init__.py
│   ├── runtime.py       # 核心循环
│   ├── tools.py         # 工具注册表
│   ├── memory.py        # 记忆系统
│   └── config.py        # 配置管理
├── examples/
│   └── basic_agent.py   # 使用Harness的示例
└── requirements.txt

这个结构的核心思想:Harness是独立于具体Agent的。你可以在examples/中写不同的Agent(天气助手、客服机器人、数据分析员),但它们都使用同一套harness/中的基础设施。

第一步:配置管理(config.py) ​

Harness的所有可调参数都应该集中在配置中,而不是散落在代码各处:

python
# harness/config.py
from dataclasses import dataclass, field
from typing import Optional

@dataclass
class LLMConfig:
    """LLM Provider的配置"""
    base_url: str = "https://api.openai.com/v1"
    api_key: str = ""
    model: str = "gpt-4o-mini"
    temperature: float = 0.0  # Agent场景建议用0,降低随机性
    max_tokens: int = 1024    # 单次LLM调用的最大输出token数

@dataclass
class RuntimeConfig:
    """Agent循环的配置"""
    max_iterations: int = 10       # 最大循环次数
    timeout_seconds: int = 60      # 总超时时间
    retry_attempts: int = 2        # 工具调用失败时的重试次数

@dataclass
class HarnessConfig:
    """Harness的总体配置"""
    llm: LLMConfig = field(default_factory=LLMConfig)
    runtime: RuntimeConfig = field(default_factory=RuntimeConfig)

使用dataclass的好处:类型清晰、默认值明确、易于扩展。当后续章节加入记忆配置、评估配置时,你可以在HarnessConfig中添加新的配置类。

第二步:工具注册表(tools.py) ​

工具注册表的最小实现只需要三个能力:定义工具、注册工具、执行工具。

python
# harness/tools.py
import json
import inspect
from typing import Any, Callable, Dict, Optional
from dataclasses import dataclass

@dataclass
class ToolResult:
    """工具执行的统一返回格式"""
    success: bool
    content: str
    error: Optional[str] = None
    tool_name: str = ""
    duration_ms: int = 0

    def to_message(self) -> Dict[str, Any]:
        """转换为LLM可读的消息格式"""
        if self.success:
            return {"role": "tool", "content": self.content}
        else:
            return {"role": "tool", "content": f"工具执行失败:{self.error}"}

class ToolRegistry:
    """工具注册表:管理Agent可用的所有工具"""

    def __init__(self):
        self._tools: Dict[str, Dict[str, Any]] = {}

    def register(self, name: str, description: str, func: Callable,
                 parameters: Dict[str, Any]):
        """注册一个工具"""
        self._tools[name] = {
            "name": name,
            "description": description,
            "func": func,
            "parameters": parameters,
        }

    def get_schemas(self) -> list:
        """获取所有工具的Schema,用于传递给LLM"""
        schemas = []
        for tool in self._tools.values():
            schemas.append({
                "type": "function",
                "function": {
                    "name": tool["name"],
                    "description": tool["description"],
                    "parameters": tool["parameters"],
                }
            })
        return schemas

    def execute(self, name: str, arguments: Dict[str, Any]) -> ToolResult:
        """执行一个工具"""
        import time
        start = time.time()

        if name not in self._tools:
            return ToolResult(
                success=False,
                content="",
                error=f"未知工具:{name}",
                tool_name=name
            )

        tool = self._tools[name]
        try:
            result = tool["func"](**arguments)
            duration = int((time.time() - start) * 1000)
            return ToolResult(
                success=True,
                content=str(result),
                tool_name=name,
                duration_ms=duration
            )
        except Exception as e:
            duration = int((time.time() - start) * 1000)
            return ToolResult(
                success=False,
                content="",
                error=str(e),
                tool_name=name,
                duration_ms=duration
            )

这个实现有几个值得注意的设计决策:

第一:ToolResult是统一的返回格式。 无论工具成功还是失败,都返回一个ToolResult对象。这让Runtime的处理逻辑变得简单——它不需要关心具体工具的行为,只需要检查success字段。

第二:工具执行永远不抛异常。 所有异常都在execute中被捕获,转换为ToolResult(success=False, error=...)。这是Harness的错误隔离原则:单个工具的失败不应该导致整个Agent崩溃。

第三:get_schemas()方法将工具定义转换为LLM所需的格式。 这是Tool Registry与LLM之间的接口。工具的定义(描述、参数Schema)直接决定了LLM能否正确使用它们。

第三步:记忆系统(memory.py) ​

最小记忆系统只需要管理短期记忆——当前会话的对话历史:

python
# harness/memory.py
from typing import List, Dict, Any

class MemorySystem:
    """记忆系统:管理Agent的对话历史"""

    def __init__(self, system_prompt: str):
        self.system_prompt = system_prompt
        self.messages: List[Dict[str, Any]] = []

    def initialize(self, user_input: str):
        """初始化一次新的会话"""
        self.messages = [
            {"role": "system", "content": self.system_prompt},
            {"role": "user", "content": user_input},
        ]

    def add_llm_message(self, message: Dict[str, Any]):
        """添加LLM的响应到对话历史"""
        self.messages.append(message)

    def add_tool_result(self, result):
        """添加工具执行结果到对话历史"""
        self.messages.append(result.to_message())

    def get_context(self) -> List[Dict[str, Any]]:
        """获取当前完整的对话上下文"""
        return self.messages

    def get_stats(self) -> Dict[str, Any]:
        """获取记忆系统的统计信息"""
        total_tokens_estimate = sum(
            len(str(m.get("content", ""))) // 4  # 粗略估算:中文约1.5-2字符/token,英文4字符/token
            for m in self.messages
        )
        return {
            "message_count": len(self.messages),
            "estimated_tokens": total_tokens_estimate,
        }

这个最小实现做了三件事:初始化会话(放入系统提示词和用户输入)、累积消息(LLM响应和工具结果)、提供上下文(给Runtime调用LLM时使用)。

注意get_stats()方法:它提供了一个粗略的token估算。在后续章节中,我们会用更精确的token计数替换这个估算。但在最小版本中,有一个"大致消耗了多少token"的概念已经很有价值。

第四步:核心循环(runtime.py) ​

这是Harness的心脏:

python
# harness/runtime.py
import json
import time
from typing import Optional
from openai import OpenAI

from .config import HarnessConfig
from .memory import MemorySystem
from .tools import ToolRegistry

class AgentResult:
    """Agent一次执行的完整结果"""
    def __init__(self, status: str, content: str = "",
                 iterations: int = 0, duration_ms: int = 0,
                 termination_reason: str = ""):
        self.status = status  # "success" | "max_iterations" | "timeout" | "error"
        self.content = content
        self.iterations = iterations
        self.duration_ms = duration_ms
        self.termination_reason = termination_reason

    def to_dict(self):
        return {
            "status": self.status,
            "content": self.content,
            "iterations": self.iterations,
            "duration_ms": self.duration_ms,
            "termination_reason": self.termination_reason,
        }

class AgentRuntime:
    """Agent运行时:驱动核心循环"""

    def __init__(self, config: HarnessConfig,
                 tool_registry: ToolRegistry,
                 memory: MemorySystem):
        self.config = config
        self.tools = tool_registry
        self.memory = memory
        self.client = OpenAI(
            base_url=config.llm.base_url,
            api_key=config.llm.api_key,
        )

    def run(self, user_input: str) -> AgentResult:
        """执行一次完整的Agent循环"""
        start_time = time.time()

        # 初始化记忆
        self.memory.initialize(user_input)

        for iteration in range(self.config.runtime.max_iterations):
            # 检查超时
            elapsed = time.time() - start_time
            if elapsed > self.config.runtime.timeout_seconds:
                return AgentResult(
                    status="timeout",
                    iterations=iteration,
                    duration_ms=int(elapsed * 1000),
                    termination_reason=f"超时(>{self.config.runtime.timeout_seconds}秒)"
                )

            # 调用LLM(感知 + 思考)
            try:
                response = self.client.chat.completions.create(
                    model=self.config.llm.model,
                    messages=self.memory.get_context(),
                    tools=self.tools.get_schemas(),
                    temperature=self.config.llm.temperature,
                    max_tokens=self.config.llm.max_tokens,
                )
            except Exception as e:
                elapsed = time.time() - start_time
                return AgentResult(
                    status="error",
                    content=f"LLM调用失败:{e}",
                    iterations=iteration,
                    duration_ms=int(elapsed * 1000),
                    termination_reason="LLM调用异常"
                )

            message = response.choices[0].message

            # 判断:LLM是否要调用工具
            if message.tool_calls:
                # 将LLM的决策加入记忆
                self.memory.add_llm_message(message)

                # 执行所有工具调用(行动)
                for tool_call in message.tool_calls:
                    name = tool_call.function.name
                    args = json.loads(tool_call.function.arguments)

                    # 执行工具(带重试)
                    result = self._execute_with_retry(name, args)

                    # 将工具结果加入记忆(感知)
                    self.memory.add_tool_result(result)
            else:
                # LLM给出了最终回答
                elapsed = time.time() - start_time
                return AgentResult(
                    status="success",
                    content=message.content or "",
                    iterations=iteration + 1,
                    duration_ms=int(elapsed * 1000),
                    termination_reason="正常终止(LLM给出最终回答)"
                )

        # 达到最大迭代次数
        elapsed = time.time() - start_time
        return AgentResult(
            status="max_iterations",
            content="已达到最大循环次数,Agent未能完成任务",
            iterations=self.config.runtime.max_iterations,
            duration_ms=int(elapsed * 1000),
            termination_reason=f"达到最大迭代次数({self.config.runtime.max_iterations}次)"
        )

    def _execute_with_retry(self, name: str, args: dict):
        """执行工具,失败时重试"""
        result = self.tools.execute(name, args)

        if result.success:
            return result

        # 重试逻辑
        for attempt in range(self.config.runtime.retry_attempts):
            print(f"  [重试 {attempt + 1}/{self.config.runtime.retry_attempts}] 工具 {name} 执行失败:{result.error}")
            result = self.tools.execute(name, args)
            if result.success:
                return result

        return result  # 重试后仍失败,返回最后一次的结果

让我们仔细理解这个Runtime的设计:

第一:AgentResult是一个结构化的结果对象。 它记录了执行的状态(成功/超时/达到上限/错误)、迭代次数、耗时、终止原因。这些信息对于后续的评估和可观测性至关重要。没有结构化的结果,就无法系统性地度量Agent的表现。

第二:终止条件被显式管理。 循环有四个可能的终止路径:LLM给出最终回答(正常)、超时、达到最大迭代次数、LLM调用异常。每条路径都返回一个带有明确termination_reason的AgentResult。

第三:工具调用带重试。 _execute_with_retry方法在工具失败时自动重试(次数由配置决定)。这是Harness对"工具可能偶发失败"这一现实的基本应对。注意:重试是在同一个工具和参数上进行的,不会改变策略——改变策略是LLM的职责(如果重试后仍失败,LLM会看到错误信息并决定下一步)。

第四:循环的每次迭代都包含了完整的"感知-思考-行动"。 LLM调用是"思考",工具执行是"行动",将结果加入记忆是"感知"。这个结构在代码中清晰可见。

第五步:组装并运行 ​

现在把这些组件组装起来:

python
# examples/basic_agent.py
import os
from harness.config import HarnessConfig, LLMConfig, RuntimeConfig
from harness.tools import ToolRegistry
from harness.memory import MemorySystem
from harness.runtime import AgentRuntime

# 1. 配置
config = HarnessConfig(
    llm=LLMConfig(
        api_key=os.getenv("OPENAI_API_KEY", ""),
        model="gpt-4o-mini",
        temperature=0.0,
    ),
    runtime=RuntimeConfig(
        max_iterations=10,
        timeout_seconds=60,
        retry_attempts=2,
    )
)

# 2. 定义工具
def get_weather(city: str) -> str:
    """查询指定城市的天气(模拟数据)"""
    mock_data = {
        "北京": "晴,26°C",
        "上海": "多云,29°C",
        "深圳": "雷阵雨,31°C",
    }
    return mock_data.get(city, f"暂无{city}的天气数据")

def calculate(expression: str) -> str:
    """计算数学表达式"""
    try:
        result = eval(expression, {"__builtins__": {}}, {})
        return f"计算结果:{result}"
    except Exception as e:
        return f"计算出错:{e}"

# 3. 注册工具
registry = ToolRegistry()
registry.register(
    name="get_weather",
    description="查询指定城市的天气",
    func=get_weather,
    parameters={
        "type": "object",
        "properties": {
            "city": {"type": "string", "description": "城市名称"}
        },
        "required": ["city"]
    }
)
registry.register(
    name="calculate",
    description="计算数学表达式",
    func=calculate,
    parameters={
        "type": "object",
        "properties": {
            "expression": {"type": "string", "description": "数学表达式"}
        },
        "required": ["expression"]
    }
)

# 4. 创建记忆系统
memory = MemorySystem(
    system_prompt="你是一个有用的助手。你可以查询天气和计算数学表达式。"
)

# 5. 创建Runtime
runtime = AgentRuntime(
    config=config,
    tool_registry=registry,
    memory=memory,
)

# 6. 运行
if __name__ == "__main__":
    result = runtime.run("北京今天多少度?这个数字乘以3再加5等于多少?")

    print("=" * 50)
    print("Agent执行结果:")
    print(f"  状态:{result.status}")
    print(f"  迭代次数:{result.iterations}")
    print(f"  耗时:{result.duration_ms}ms")
    print(f"  终止原因:{result.termination_reason}")
    print(f"  回答:{result.content}")
    print("=" * 50)

    # 查看记忆统计
    stats = memory.get_stats()
    print(f"记忆统计:{stats}")

运行:

shell
export OPENAI_API_KEY="sk-..."
python examples/basic_agent.py

输出示例:

shell
==================================================
Agent执行结果:
  状态:success
  迭代次数:3
  耗时:4200ms
  终止原因:正常终止(LLM给出最终回答)
  回答:北京今天26°C。26 × 3 + 5 = 83。
==================================================
记忆统计:{'message_count': 6, 'estimated_tokens': 850}

这个最小Harness与1.5节的脚本有什么区别? ​

维度1.5节的脚本本节的Harness
配置管理硬编码在代码中集中在HarnessConfig中
工具管理散落的字典和函数映射独立的ToolRegistry
记忆管理一个消息列表独立的MemorySystem,带统计
执行结果直接返回文本结构化的AgentResult
错误处理基本没有工具异常捕获、LLM异常捕获、重试机制
终止条件只有最大迭代次数超时、最大迭代、异常、正常终止
可复用性每次写新Agent都要复制代码Harness是独立的,可被多个Agent复用

这些区别的本质是:从"写一段能跑的代码"到"构建一个可维护的系统"。 每个区别单独看都不大,但它们合在一起,意味着你可以在这个Harness上继续构建更复杂的能力,而1.5节的脚本则会随着功能增加而迅速失控。

本节小结 ​

你现在有了一个最小但结构正确的Harness。它包含:

  • config.py:集中管理LLM和Runtime的配置
  • tools.py:ToolRegistry管理工具的定义、注册和执行,ToolResult统一工具返回格式
  • memory.py:MemorySystem管理对话历史,提供上下文和统计
  • runtime.py:AgentRuntime驱动核心循环,管理终止条件和错误处理

这个骨架的关键特征是模块边界清晰。当后续章节加入更多能力时——工具沙箱(第4章)、长期记忆(第5章)、评估体系(第7章)、可观测性(第9章)——它们会作为新的模块加入,而不是在现有代码上打补丁。

在下一节中,我们将为这个Harness添加结构化输出与解析能力,让Agent不仅能调用工具,还能以受控的格式返回结果。