Harness Engineering:将大语言模型转化为智能体的运行时系统
究竟是什么让编程代理能够正常运行:循环结构、能在错误调用时依然正常工作的工具调度机制,以及允许/请求/拒绝权限的功能,全部通过约600行纯Python代码展示出来。
来源: 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。
- 当没有工具调用需要执行时,任务即完成并显示回复。
聊天机器人只进行一次往返通信,而智能体会持续运行直到任务完成。这就是两者之间的全部区别,其余的大部分工程工作都是为了防止循环出现异常而设计的。
有一点会让新手感到困惑:模型是无状态的。它们不会记住之前的对话轮次,因此每次 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中的防御性调度函数,其执行顺序如下:
- 解析JSON,如果格式错误,则返回错误信息,说明传入的参数不是有效的JSON。
- 查找工具名称,如果找不到,则列出实际存在的工具。
- 调用
permissions.check()以获取拒绝、询问或允许的决策结果。 - 调用相应的Python函数。
- 捕获
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 实际却是其他操作。而沙箱机制才是安全保障,因为它能判断某个操作是否根本在物理层面上被允许。一个真正的编程代理需要两者兼备。
这给你的启示
循环、防御工具以及权限设置已经足以构成一个可用的编程代理。它能够读取代码库、运行测试、编辑文件,在执行危险操作前进行询问,并从格式错误的工具调用中恢复。
目前它还不安全,因为具备足够智能的模型仍可能造成损害。它的效率也很低,因为上下文窗口会很快被填满。此外它没有记忆功能,每次会话都得从零开始。接下来的自然发展方向是建立操作系统级的沙箱环境,让破坏性操作在内核层面就被阻止,同时通过分层上下文管理来维持长时间会话的稳定性。这些其实与生产环境中的智能体所使用的机制相同,只是去掉了那些神秘因素。
相关阅读
- 实用笔记:“MCP:将AI从聊天机器人转变为AI智能体的协议” — 该实用笔记的详细操作指南,内容包括合同定义、校验规则以及供团队直接使用的代码模板,帮助其构建MCP系统。