首页 / 文章

Harness Engineering:将大语言模型转化为智能体的运行时系统

究竟是什么让编程代理能够正常运行:循环结构、能在错误调用时依然正常工作的工具调度机制,以及允许/请求/拒绝权限的功能,全部通过约600行纯Python代码展示出来。

发布者:REACTAPP.TOP阅读约 13 分钟2950 词

来源: Harness Engineering: Build the Runtime That Turns an LLM Into an Agent (Part 1/3)

当编码代理出错时,通常都是模型被指责。但实际上问题往往出在围绕它运行的程序框架中。

任何使用过聊天完成 API 的人都知道其中简单的部分:发送消息、接收回复,或许再添加函数调用。困难的部分则在于如何让模型能够驻留在代码仓库中、编辑文件、运行测试,并在出错后自行恢复。程序框架正是填补这一差距的存在。它是模型周围的运行环境:包括各种工具、权限控制、沙箱机制、上下文处理功能以及内存和错误恢复机制。模型所做的仅仅是处理输入文本和输出文本。而程序框架则是负责循环执行这些操作、调用相关工具、保障系统安全,并在终端中与用户交互的程序。如果说模型是引擎,那么程序框架就是汽车。

仅凭语言模型本身,它无法打开你的文件、运行 pytest、编辑代码、回忆上星期二的对话,也无法阻止自己输入 rm -rf /。它唯一能做的就是输出下一条消息。其余所有功能都需要通过其他机制来实现:

  • 循环:不断调用模型,直到它不再请求工具。
  • 工具:用于执行操作的手脚(如shell命令、文件操作、编辑功能)。
  • 权限控制:在执行高风险操作前进行确认,并阻止最危险的操作。
  • 沙箱环境:在操作系统层面确保危险操作无法成功执行。
  • 上下文管理:将对话内容控制在可传输的范围内。
  • 内存管理:存储技能信息、待办列表以及可恢复的会话文件。
  • 子代理:在不占用主对话空间的情况下探索代码库。

本文介绍了前三个要素,正是它们让聊天机器人转变为智能代理。其余的沙箱环境、上下文管理、内存机制以及子代理,都是在这个基础架构之上添加的进一步功能。

聊天机器人与智能代理

聊天助手仅完成一次往返:输入问题,输出答案。而像 Claude Code 或 Cursor 的智能代理模式则运作方式不同。当你输入“重构这个模块”时,它们会读取文件、运行测试、编辑代码、再次运行测试,并修复所有错误。这并非因为模型更智能,而是因为围绕该模型设计的程序懂得如何循环执行、调用工具,以及在某一步失败时继续运行。

人们开始将这种技术称为“框架工程”,其实这只是一个简单概念的华丽称谓:Claude Code、Cursor 和 Codex 之所以更优秀,既在于其更强大的模型,也在于它们更完善的框架设计。

示例比定义更有帮助。你输入:“修复 test_parser.py 中的失败测试。”随后会出现如下内容:

Turn 1:  LLM reads the test file         → bash("cat test_parser.py")
Turn 2:  LLM runs the test to see error  → bash("pytest test_parser.py")
Turn 3:  LLM reads the source file       → read_file("parser.py")
Turn 4:  LLM edits the bug               → str_replace("parser.py", old, new)
Turn 5:  LLM re-runs the test            → bash("pytest test_parser.py")
Turn 6:  Tests pass → LLM says "Done! Fixed the off-by-one error in line 42."

六轮对话与六次决策,而在第一次提示之后就再也没有人点击过任何内容。这正体现了该工具的有效性。

词汇表

有一些术语在文中反复出现。

  • LLM:一种将消息列表转换为另一条消息的函数。它不会记住之前的API调用记录。若想让它显得有记忆功能,就需要再次发送历史记录。
  • Token:模型计数的文本单位,大约相当于一个英文单词的四分之三。“Hello world”大约包含两个Token,而一个100行的Python文件则约有500个Token。
  • 上下文窗口:模型一次能够处理的Token数量。GPT-4级别的模型上下文窗口约为128K,Claude的则为200K,这些数字听起来似乎很大,但当某个智能体将grep -r的输出内容复制到聊天框中40次后,情况就变了。
  • 工具调用:模型不会直接运行你的代码。它会生成类似 {"name": "bash", "arguments": {"command": "ls"}} 的请求,随后由你的 Python 程序执行 ls 命令并返回结果。
  • 无状态:每次 call_llm(messages) 都是独立的操作,而 messages 列表则充当内存。
  • 无需机器学习背景。掌握基础 Python 语法(函数、循环、字典、try/except),有一些使用 OpenAI 风格 API 的经验,以及常见的shell命令(ls、grep、cat)即可。

    参考实现

    这个示例智能体大约包含600行Python代码,未使用任何框架,仅依赖四个库:openai、rich、prompt-toolkit和pyyaml。以下的代码片段均为实际代码而非伪代码,读完之后你应该能认出Claude Code或Cursor中的对应部分。如需跟随学习,请克隆该项目并以可编辑模式安装:

    git clone https://github.com/shrinidhi-mahishi/coding-harness.git
    cd coding-harness
    pip install -e .
    

    任何兼容 OpenAI 的端点都可以使用:OpenRouter、本地服务器或 OpenAI 本身。将相关设置放入 ~/.agents/env 中:

    BASE_URL=https://openrouter.ai/api/v1
    API_KEY=sk-or-...
    MODEL=deepseek/deepseek-v4-flash
    

    真实的环境变量会覆盖该文件。在pyproject.toml中作为coding_harness.agent:main的入口点声明的coding-harness命令会启动一个终端会话。你输入内容后,内部循环就会持续运行,直到该代理没有更多的工具调用需要执行。

    整个设计由七个要素构成:代理循环(核心机制)、工具(执行操作的手)、权限(谁可以做什么)、沙箱(由内核保障的安全性)、上下文管理(清晰的界面)、技能、待办事项与会话(内存存储),以及子代理(将上下文存储在可丢弃的位置)。此处仅介绍前三个要素。

    相关的文件有:

    coding_harness/agent.py         # two nested loops
    coding_harness/llm.py           # one API call + the system prompt
    coding_harness/tools.py         # tool functions + defensive execute()
    coding_harness/permissions.py   # allow / ask / deny
    coding_harness/config.py        # BASE_URL, API_KEY, MODEL
    

    没有数据库、Web服务器或编排库。这个“框架”仅由两个while True循环以及一个工具函数字典组成。初次使用时,请按顺序查看config.py、llm.py、agent.py、tools.py和permissions.py。

    1. 智能体循环

    Claude Code、Cursor和Codex都采用相同的循环结构:

    1. 将对话内容发送给模型。
    2. 如果模型请求使用工具,则执行该工具。
    3. 将工具执行结果添加到对话中,然后返回步骤1。
    4. 当没有工具调用需要执行时,任务即完成并显示回复。

    聊天机器人只进行一次往返通信,而智能体会持续运行直到任务完成。这就是两者之间的全部区别,其余的大部分工程工作都是为了防止循环出现异常而设计的。

    有一点会让新手感到困惑:模型是无状态的。它们不会记住之前的对话轮次,因此每次 API 调用都需要携带完整的对话内容。消息形式如下:

    messages = [
        {"role": "system",    "content": "You are a coding agent..."},
        {"role": "user",      "content": "Fix the failing test"},
        {"role": "assistant", "content": "I'll start by reading the test file."},
        {"role": "tool",      "content": "... contents of test file ..."},
    ]
    

    共有四种角色。系统角色负责存储固定指令,用户即为你,助手为模型,而工具则用于传递模型返回的输出结果。模型接收该列表并生成下一条内容,而该列表就是它唯一的“记忆”。不存在隐藏的备用存储,因此如果列表有误,模型的输出也会出错。

    整个流程涉及两个循环。内部循环处理单个用户的输入以及模型所需的相应次数响应,外部循环则仅负责等待输入。

    这是简化后的内部循环结构。实际文件还会删除旧的工具输出,在每次请求的末尾添加新的提醒,清除临时文件,并能压缩历史记录。

    while True:
        message, usage = call_llm(messages)
    
        messages.append(message.model_dump(exclude_none=True))
            if message.content:
                ui.agent(message.content)
            # No tool calls? The agent is done thinking.
            if not message.tool_calls:
                break
            # Execute each tool and feed the result back
            for tool_call in message.tool_calls:
                args, result = execute(tool_call)
                ui.tool(tool_call.function.name, args, result)
                messages.append({
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "content": result,
                })
    

    代码位于 agent.py 中。call_llm(messages) 会发送完整的历史记录,模型则会返回文本、工具调用结果或两者兼有。model_dump() 仅将 API 对象转换为可存储的字典。如果没有工具调用,循环就会终止;否则它会执行这些工具调用,展示结果,将结果追加后再次循环,这样模型就能了解发生了什么,并决定是否继续。

    最外层的循环规模更小:

    while True:
        user_input = ui.ask()
        if not user_input:
            break
    
        messages.append({"role": "user", "content": user_input})
            # ... inner agent loop runs here ...
    

    你的文本会被加入列表,内部循环会一直运行直到智能体处理完毕,之后程序又会再次等待。这里没有状态机,只有两个嵌套的while True语句,这与Claude Code和Cursor的架构相同。模型会发言或请求使用工具,工具的运行结果会反馈到messages中,然后这个循环不断重复。

    2. 工具:模型的“手”

    如果没有工具,这个循环只不过是一个多了几步的聊天机器人而已。工具则是模型完成任务的手段:读取文件、运行命令、编写代码、搜索代码库。

    想象一下,让一个聪明的人在没有电脑的锁闭房间里修复错误——他们可以思考,却无法触碰任何东西。工具就是那扇门。

    许多教程都忽略了这样一个事实:模型本身永远不会执行这些工具。模型只会生成类似“运行此命令”的请求,实际的执行工作以及结果返回都是由周围的Python代码完成的。模型没有shell环境,只有相应的表单结构。

    一个工具由四部分组成:名称(如bash、read_file)、用于告知模型何时使用该工具的描述、包含必填字段和类型的参数JSON Schema,以及负责执行具体任务的Python函数。

    bash既是功能最强大的工具,也最令人担忧。以下是其来自tools.py的代码示例:

    def bash(command: str) -> str:
        """Run a shell command and return its combined stdout and stderr."""
        try:
            result = sandbox.run(command)
        except subprocess.TimeoutExpired as expired:
            return (
                f"Timed out after {expired.timeout}s and was killed. "
                "Narrow it down - search inside the working directory rather than /."
            )
        return (result.stdout + result.stderr) or "(no output)"
    

    模型所看到的是该架构:

    {
        "type": "function",
        "function": {
            "name": "bash",
            "description": "Run a shell command and return its combined stdout and stderr.",
            "parameters": {
                "type": "object",
                "properties": {
                    "command": {
                        "type": "string",
                        "description": "The shell command to run",
                    }
                },
                "required": ["command"],
            },
        },
    }
    

    当模型需要目录列表时,它会输出 {"name": "bash", "arguments": {"command": "ls -la src/"}}。代码会执行该指令并返回结果,模型再据此决定下一步操作。

    该智能体拥有七种工具,每种工具都由一个函数和一个结构定义组成:

    • bash 通过沙箱环境执行shell命令。
    • read_file 用于读取文件。
    • write_file 用于创建或覆盖文件。
    • str_replace 通过将一个字符串替换为另一个来实现精确编辑。
    • read_skill 用于加载 SKILL.md 中的操作指南。
    • write_todos 用于替换整个待办列表。
    • task 用于启动子智能体,并仅返回其最终答案。

    bash函数只是一个简单的封装:它会通过sandbox.run执行任务,设置60秒的超时时间,然后返回标准输出和标准错误信息;如果超时,则会返回提示在项目目录内搜索而非在整个/路径下搜索的消息。

    将工具调用视为不可信的输入

    很多“20行代码构建代理”的教程都会跳过这一部分。工具调用实际上是模型生成的文本,因此其威胁模型与网页表单类似。对应的名称可能并不存在,参数可能不是有效的JSON格式,类型也可能错误。如果代码没有做好相应处理,任何一种情况都可能导致会话中断。

    模型可能会产生幻觉,编造函数名称或输出格式错误的JSON数据。一旦出现崩溃,用户就不得不重新开始。如果你能及时发现问题并返回易于理解的错误信息,模型通常会再次尝试并正确执行任务,而且它在这方面表现得相当出色,前提是你给它的应该是错误信息而非堆栈跟踪信息。

    execute()也是位于tools.py中的防御性调度函数,其执行顺序如下:

    1. 解析JSON,如果格式错误,则返回错误信息,说明传入的参数不是有效的JSON。
    2. 查找工具名称,如果找不到,则列出实际存在的工具。
    3. 调用permissions.check()以获取拒绝、询问或允许的决策结果。
    4. 调用相应的Python函数。
    5. 捕获TypeError及其他任何异常,返回“错误:……”这样的字符串,而绝不输出错误堆栈信息。

    简而言之,它能在不导致会话崩溃的情况下将工具调用路由到对应的函数。

    def execute(tool_call):
        name = tool_call.function.name
    
    # The model might send broken JSON
        try:
            args = json.loads(tool_call.function.arguments)
        except json.JSONDecodeError as broken:
            return {}, f"Error: arguments were not valid JSON ({broken})."
        # The model might hallucinate a tool name
        if name not in TOOLS:
            return args, f"Error: no tool named '{name}'. Available: {', '.join(TOOLS)}."
        # Run through the permission layer, then call the function
        try:
            action, reason = check(name, args)
            if action == "deny":
                return args, f"Blocked by policy: {reason}"
            if action == "ask" and not ui.approve(reason):
                return args, "The user denied this tool call."
            return args, TOOLS[name](**args)
        except TypeError as mismatch:
            return args, f"Error: wrong arguments for {name} ({mismatch})."
        except Exception as failure:
            return args, f"Error: {name} failed - {type(failure).__name__}: {failure}"
    

    假设模型发明了一个名为 search_code 的工具。虽然其 JSON 结构没有问题,但该名称并不在 TOOLS 列表中,因此调度器会回复称不存在这样的工具,并列出现有的工具:bash、read_file、write_file、str_replace、read_skill、write_todos 和 task。在下一轮中,模型使用 bash 并结合 grep -r "auth" . 命令。由于没有出现崩溃,这正是目的所在:错误的工具调用绝不能导致会话中断,错误仅被视为额外的输入而已。

    3. 权限:谁可以做什么

    有了 bash 后,代理程序就变得非常有用,但这也意味着用户可能会执行 rm -rf / 这类危险命令或将敏感信息发送到不明地址。如果代理程序能够运行任何命令,它就有可能删除文件、上传垃圾数据或向外部发送信息。如果必须对每个命令都进行确认,那整个下午都将耗费在审批 ls 这类简单命令上。因此需要找到一个折中的方案。

    答案分为三个级别,类似于交通信号灯:

    级别 含义 示例
    允许 可静默运行 ls、grep、cat、git status、pytest
    询问 会暂停等待用户操作 mkdir、python script.py、npm install
    拒绝 即使用户同意也会被阻止 rm、sudo、curl、wget、git push

    这些规则是通配符模式,其中*表示任意内容,最后匹配到的规则生效。出自permissions.py:

    BASH_RULES = {
        "*":                   "ask",     # default: ask for everything
        # read-only: let them through
        "ls*":                 "allow",
        "cat *":               "allow",
        "grep *":              "allow",
        "git status*":         "allow",
        "git diff*":           "allow",
        "pytest*":             "allow",
        # risky: never, even if the user says yes
        "rm *":                "deny",
        "sudo *":              "deny",
        "curl *":              "deny",
        "git push*":           "deny",
    }
    

    * 这一选项会将默认设置设为询问模式。只读命令会被允许执行,危险命令则会被直接阻止,而诸如 python script.py 或 npm install 之类的其他命令则仍保持询问模式。

    该模型会尝试使用一些巧妙的手段。像 grep foo | rm -rf / 这样的命令看似无害,实则会导致严重后果。因此检查器会按照 |、; 和 && 进行分割,同时考虑引号的影响,并对每一部分应用最严格的判定标准:

    def decide(command):
        """Rate every part of a compound command; the strictest verdict wins."""
        verdicts = []
        for part in split_command(command):
            action = "ask"
            for pattern, rule in BASH_RULES.items():
                if fnmatch(part, pattern):
                    action = rule
            verdicts.append(action)
    
        for strictest in ("deny", "ask"):
                if strictest in verdicts:
                    return strictest
            return "allow"
    

    实际上,grep foo | rm -rf / 会因 rm 被禁止而无法执行。ls && mkdir test 则会触发询问,因为 mkdir 不在允许列表中。cat file.py | grep TODO 可以正常执行,因为这两部分操作都是只读的。

    权限与沙箱机制承担不同的功能。权限关乎用户体验:这件事值得打扰你吗?它们并非安全措施,因为一个巧妙的命令可以伪装成 ls 实际却是其他操作。而沙箱机制才是安全保障,因为它能判断某个操作是否根本在物理层面上被允许。一个真正的编程代理需要两者兼备。

    这给你的启示

    循环、防御工具以及权限设置已经足以构成一个可用的编程代理。它能够读取代码库、运行测试、编辑文件,在执行危险操作前进行询问,并从格式错误的工具调用中恢复。

    目前它还不安全,因为具备足够智能的模型仍可能造成损害。它的效率也很低,因为上下文窗口会很快被填满。此外它没有记忆功能,每次会话都得从零开始。接下来的自然发展方向是建立操作系统级的沙箱环境,让破坏性操作在内核层面就被阻止,同时通过分层上下文管理来维持长时间会话的稳定性。这些其实与生产环境中的智能体所使用的机制相同,只是去掉了那些神秘因素。

    相关阅读

  • 用 Python 从零构建 AI 智能体:逐步讲解 GAME 循环 —— 使用目标、动作、内存和环境等要素,用纯 Python 构建一个能够调用工具的智能体,随后再添加工具注册表、隐藏的依赖注入功能以及类型化输出。
  • 让 AI 智能体保持代码库内存准确性的 LangGraph 流水线 —— 构建一个 LangGraph 流水线,用于生成各服务对应的知识文件,通过 Ragas 和 DeepEval 对这些文件进行筛选,在 Langfuse 中追踪执行过程,同时也能在 Ollama 上本地运行。
  • 对编程智能体进行沙箱隔离并保持其上下文窗口的精简 — 为智能体添加安全沙箱,并通过溢出文件、占位符、数据压缩以及适合前缀缓存机制的延迟注入方式来管理上下文。
  • 应对长时间运行挑战:实现智能体会话持久化与数据压缩的方案 — 如何让智能体会话在成本可控的前提下保持连贯性:以缓存尾部作为“租金”机制、在数据压缩前设置五个处理步骤、利用委托机制界定内存边界,以及处理恢复会话的代价。
  • 面向 Java 团队的 LangChain4j:Spring 服务中的工具、智能体、技能及端到端功能 — 如何利用 LangChain4j 将工具调用、智能体流水线、按需技能、可暂停与恢复的工作流以及远程端到端智能体集成到现有的 Java 服务中。