写在前面:今天这节课的主题叫 Harness Engineering(智能体工程) ,readme 开篇一句话给出了定义------"用工程化手段,让 AI Agent 可靠、持续完成任务。" 这句话里有两个关键词:可靠 、持续。会调 API 的人很多,但能让 AI 稳定地干完一件长活的人不多。上几节课学的 Memory、LangGraph、结构化输出、SSE------这些其实都是 Harness 的零件。今天这节是总纲:把这些零件装进同一张图纸。readme 还给出了对应的岗位画像------"AI 应用开发工程师(Agentic RAG 应用)、AI Agent 开发工程师(Harness Agent FDE)"。以下所有代码和概念均来自课堂真实文件。
一、LLM 是个没入职的应届生
先想一个问题------LLM 那么聪明,为什么不能直接拿来干活?
因为它是个**"裸脑"**:会说话、会推理,但:
| 缺什么 | 后果 |
|---|---|
| 没有手 | 不能读写文件、不能跑命令 |
| 没有记忆 | 关掉即失忆(前面讲过的"金鱼") |
| 没有边界 | 要是真给了它命令执行权,它能 rm -rf / |
| 没有履历 | 出了问题你不知道它刚才做了什么 |
Harness 就是给这颗"裸脑"配的一整套办公设施------工位、工具箱、资料柜、门禁、笔记本、监控。装完之后,它才从"聊天机器人"变成"Agent"。
readme 里那句被反复提到的公式,今天终于要完整展开了:
ini
Agent = LLM(大脑)+ Harness(tool + mcp + rag + skill ... + ...)
二、六大基础模块:一间办公室的六件标配
readme 列了六个模块,说得很清楚:
"包含 6 个基础模块。"
| 模块 | 职责 | 办公室类比 |
|---|---|---|
| Loop 主循环 | 核心控制层,调用大模型、分发工具调用、判断终止条件 | 工作节奏 |
| Tool 工具集 | LLM 能使用的工具集 | 工具箱 |
| Context 上下文管理器 | 输入给模型的全部内容 + 压缩 + 窗口控制 | 桌上的资料 |
| Environment 沙箱环境 | 工具运行时的隔离环境 | 工位与门禁 |
| Memory 记忆层 | 跨轮次、跨会话状态持久化 | 笔记本 |
| Observability 可观察性 | 日志、Trace、评估指标、调试、回放、评测 | 监控与复盘 |
逐个看。
1. Loop 主循环:心跳
readme 原文:
"Loop 主循环------自主长时间干活。ReAct。核心控制层,调用大模型,分发工具调用,判断任务终止条件。Agent 的主执行流。"
这是整个 Agent 的心脏。它干的事很简单,但必须永不停歇:
markdown
while (任务未完成) {
1. 把当前上下文发给 LLM
2. LLM 决定:直接回答?还是调用工具?
3. 如果调工具 → 执行 → 把结果塞回上下文 → 回到第 1 步
4. 如果直接回答 → 结束
}
这就是 ReAct(Reason + Act)------推理一步、行动一步、观察结果、再推理。前面讲 Memory 时提到过这个流程,今天它是六大模块里的第一号。
关键在"判断终止条件"------LLM 可能陷入死循环(反复调同一个工具),也可能话说到一半就停。主循环必须管住这两件事。
2. Tool 工具集:给它一双手
"Tool 工具集,llm 能使用的工具集。"
工具的本质是把 LLM 的"文字能力"变成"行动能力"。前面学的结构化输出、Tool Call、JSON Schema 全部用在这里------每个工具都需要:
- 名字(
bash、read_file) - 描述(LLM 靠它判断什么时候用)
- 参数 Schema(LLM 靠它生成合法参数)
agent.py 里有 4 个基础工具(下面细讲)------bash、read_file、write_file、edit_file。就这四个,已经能覆盖"写代码"这件事的绝大部分场景了。
3. Context 上下文管理器:资料怎么摆
"Context 上下文管理器。无状态。输入给模型的全部内容:系统提示词、历史对话、rag、检索、es 上下文。工具返回结果。上下文压缩、窗口控制。"
这里有个反直觉的点------readme 特意标了**"无状态"**。
LLM 本身是无状态的(每次调用都不记得上次),所以"上下文管理器"的职责就是:每次调用前,把该给的资料全都准备好,摆到它面前。
包括:
| 内容 | 来源 |
|---|---|
| 系统提示词 | 代码里写死的 SYSTEM |
| 历史对话 | messages 数组 / Memory |
| RAG 检索结果 | 向量数据库(前面学的 Milvus) |
| 工具返回结果 | 上一轮工具的执行输出 |
而且还要管**"窗口控制"和"上下文压缩"**------前面学的截断、总结、检索,就是这一模块的具体手段。工具返回了 50000 字符的结果?得压缩。历史对话太长了?得截断。
4. Environment 沙箱环境:工位与门禁
"Environment 沙箱环境。工具运行时的隔离环境,文件系统、网络、权限、工作区隔离。"
这条是安全底线。你不能让 AI 在整台电脑上随便跑命令------它得被关在一个"工位"里:
- 文件系统:只能读写指定目录
- 网络:能访问哪些地址
- 权限:能执行哪些命令
- 工作区隔离:一个任务的改动不能污染另一个任务
agent.py 里的 safe_path() 就是这一模块的落地------所有文件操作都要先过这道安检。
5. Memory 记忆层:笔记本
"Memory 记忆层。跨轮次、跨会话状态持久化状态,短期记忆 + 长期记忆。"
这一模块前面上了整整两节课(金鱼记忆上/下篇)------短期用内存/文件,长期用向量数据库。今天不重复,只强调它的定位:Memory 是六大模块之一,不是可选项。
6. Observability 可观察性:监控与复盘
"Observability 可观察性。日志(分析)、Trace、评估指标、调试、回放、评测。"
这是最容易被忽视、但在生产环境最救命的一环。
想象一下:Agent 跑了 20 步,最后给了一个错误答案。你怎么办?
- 没有 Observability:一脸茫然,不知道哪一步错了
- 有 Observability:看 Trace,第 7 步读错了文件 → 第 12 步基于错误信息推理 → 定位到根因
readme 列了五个能力:日志、Trace、评估指标、调试、回放、评测。其中"回放"最有意思------把整个执行过程重放一遍,像看录像一样复盘。
三、高级模块:从单人到团队
readme 说得很清楚:
"6 大基础模块是最小内核;sub-Agents 属于高级编排的扩展模块,不是底层必选,但现代 Agent Harness 工程体系普遍把它作为标准模块。"
除了六大内核,还有几个可插拔的高级模块:
| 模块 | 作用 |
|---|---|
| Sub Agents | 子智能体,任务拆分与并行 |
| hooks | 生命周期钩子 |
| Policy & Safety | 权限、输出过滤、资源配额 |
Sub Agents:外包团队
"职责清晰,拆分。上下文互不干扰。主 Agent 分配任务给子 Agent,子 Agent 像子进程,上下文不被打扰。主 Agent 也不会因为子 Agent 的运行,上下文受拖累。"
"上下文互不干扰"------这七个字是 Sub Agent 存在的根本理由。
举个具体例子,readme 里那个"多 Agent 分工":
css
Agent A 写前端代码
Agent B 写后端代码
Agent C 写测试
Agent D 部署
如果全让一个 Agent 干------它的上下文里会堆满前端代码、后端代码、测试代码、部署脚本,全都搅在一起。而拆成四个子 Agent:
- A 的上下文里只有前端相关
- B 的上下文里只有后端相关
- 主 Agent 只需要知道"A 完成了、B 完成了"
readme 还点出了"按需加载工具"这一层:
"派发任务给子 Agent,独立的运行上下文,按需加载 tools,不用一次性加载那么多。"
这也呼应了前面 LangGraph 那节课讲的------单 Agent 把所有工具描述都塞进 system prompt,token 贵还干扰思考。 拆成子 Agent 后,每个子 Agent 只带自己需要的工具。
四、为什么是 Agent,而不是 Workflow?
这是 readme 里最有含金量的一段,值得单独开一节。
"固定的 workflow 是预先写死的步骤,(coze/dify/n8n 23-25 年,较固定、简单的任务),只能按预设路径执行,无法应对不确定、动态变化的任务。多 Agent(含 subAgent)可自主拆解任务,按需调用能力,根据中间结果调整执行分支,适合需求模糊、存在未知问题的复杂场景,具备更强的自适应与容错能力。"
Workflow 是一台自动售货机------投币、选号、出货。路径写死了,遇到问题不会变通。
Agent 是一个员工------你交给他一个模糊的任务,他自己决定先做什么、用什么工具、遇到问题怎么绕。
readme 给了一个特别好的对比案例:做一份行业技术调研报告。
简单版本:Workflow 就够了
问题 → 行业关键字 → 技术关键字 → 上网搜 → 分析 → 生成报告
六个固定步骤,一气呵成。这种任务路径明确、没有意外,Workflow 又稳又便宜。
专业版本:必须上多 Agent
"任务是专业的。分析需要哪些 agent?"
readme 列出的团队配置:
| 角色 | 职责 |
|---|---|
| 主 Agent | 负责任务拆解与整体调度 |
| 子 Agent(检索) | 检索资料、反爬、判断资料优劣、自动分析、切换数据源 |
| 摘要 Agent | 压缩提取 |
| 校验 Agent | "不要信资料,网上要有些分辨的" |
| 情报 Agent | 找到行业内大佬的联系方式 |
| 报告 Agent | 生成最终报告 |
注意那个校验 Agent 的说明------readme 原话:
"不要信资料,网上要有些分辨的。"
这句话道破了多 Agent 的真正价值------不是并行提速,而是互相制衡。 检索 Agent 找来的资料,要经过校验 Agent 质疑真伪。跟前面 LangGraph 那节课提到的"AutoGen 法庭"是同一个思路。
而 Workflow 做不到这一点------它的第六步永远是"生成报告",不会因为第五步发现资料可疑就临时插入一个"再查一遍"的分支。
动态调整执行分支,这才是 Agent 相对 Workflow 的质变。
五、多 Agent 的业务隔离:三件套
readme 末尾列了多 Agent 隔离的三个手段:
"专属 SYSTEM prompt(多个 system,主 system 规划、分工,子 system 执行任务);子进程;独立的上下文(子 Agent 返回结果,有全新的上下文,不会干扰主 Agent 上下文)。"
| 隔离手段 | 隔离了什么 |
|---|---|
| 专属 SYSTEM prompt | 角色定位------主 Agent 规划、子 Agent 执行 |
| 子进程 | 进程空间 |
| 独立上下文 | 对话历史 |
这三条在 agent.py 里全部有对应实现,我们马上进代码。
六、338 行代码:把六大模块装进一间办公室
现在进入今天的重头戏------agent.py。这份代码不长(338 行),但六大模块一个不少。我们按模块拆。
配置层:办公室的门牌号
python
import os
import re
import subprocess
from pathlib import Path
import json
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv(override=True)
# Agent 工作目录 安全的,被授权的
WORKDIR = Path.cwd()
# 从 .env 读取模型名,全脚本统一用这一个常量
MODEL = os.getenv("DEEPSEEK_MODEL")
client = OpenAI(
base_url=os.getenv("DEEPSEEK_BASE_URL"),
api_key=os.getenv("DEEPSEEK_API_KEY"),
)
代码注释里有句话很关键:
"Agent 工作目录,安全的,被授权的。"
WORKDIR = Path.cwd() ------ 这不是随便拿个路径,这是划定"办公室"的范围。后面所有文件操作都不能迈出这个门。
还有个小知识点,代码注释特意提了:
"python 没有常量变量之分,都是变量,用约定大写来表达"
Python 没有 const,全靠全大写命名约定表示"这是常量,别改"。
Environment 模块:门禁系统
python
def safe_path(p: str) -> Path:
# pathlib.Path 特有的 / 运算符,不是除法,运算符的重载
# 相当于路径的拼接
path = (WORKDIR / p).resolve()
# 逻辑判断语法
if not path.is_relative_to(WORKDIR):
# 抛出异常
raise ValueError(f"Path escapes workspace:{p}")
return path
十行代码,干掉了整个"逃出工作目录"的攻击面。两个细节值得说:
第一,WORKDIR / p 是运算符重载。 注释专门解释了:
"pathlib.Path 特有的 / 运算符,不是除法,运算符的重载。"
Python 里 / 本来是除法,但 Path 对象重载了它------变成路径拼接。所以 WORKDIR / "src/app.py" 得到 /Users/xx/project/src/app.py。
第二,.resolve() 之后再校验。 为什么要 resolve?因为 ../../etc/passwd 这种路径,不 resolve 你根本看不出它跑哪去了。resolve 把 .. 全部展开成绝对路径,然后才判断 is_relative_to(WORKDIR)。
如果 LLM 试图访问 ../../../etc/passwd(经典路径穿越攻击),第一次校验就会拦住------抛 ValueError 而不是"默默允许"。
这就是 readme 说的"工作区隔离"。
Policy & Safety 模块:危险命令黑名单
python
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", ">/dev/"]
# any() 就是只有一个满足就返回真
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked."
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, errors="replace", timeout=120,
)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout(120s)"
except (FileNotFoundError, OSError) as e:
return f"Error: {e}"
这是"Policy & Safety"模块的极简落地版------一份危险命令黑名单:
| 危险命令 | 拦它的原因 |
|---|---|
rm -rf / |
删库跑路一条龙 |
sudo |
提权,超出授权范围 |
shutdown / reboot |
把服务器关了 |
>/dev/ |
直接写设备文件 |
注意这里的黑名单策略是"字符串包含判断" (d in command)------非常朴素,甚至可以说很脆弱(稍微变形就能绕过)。但它体现了这个模块的定位:Policy & Safety 不是随便写写,是需要认真设计的一层。
三个工程细节也在这里:
| 细节 | 代码 | 作用 |
|---|---|---|
| 超时保护 | timeout=120 |
命令卡死不至于拖死 Agent |
| 输出合并 | r.stdout + r.stderr |
错误信息也是信息,LLM 需要看到 |
| 长度截断 | out[:50000] |
防止一次输出撑爆上下文窗口 |
那个 out[:50000] 尤其重要------它正是 Context 模块"窗口控制"的具体实现。 如果 ls -R 输出了 20 万字符全塞给 LLM,下一次调用可能直接超限。
Tool 模块:四个工具,四双手
python
def run_read(path: str, limit: int = None) -> str:
try:
lines = safe_path(path).read_text(encoding="utf-8").splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... {len(lines) - limit} more"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content, encoding="utf-8")
return f"Wrote {len(content)} bytes"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = safe_path(path)
content = fp.read_text(encoding="utf-8")
if old_text not in content:
return f"Error: Text not found in {path}"
fp.write_text(content.replace(old_text, new_text, 1), encoding="utf-8")
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
三个文件工具,各有讲究:
run_read 的"省略提示":
python
lines = lines[:limit] + [f"... {len(lines) - limit} more"]
读文件时如果超了 limit,不是简单截断,而是在末尾加一行提示"还有 N 行没显示"------这样 LLM 知道"内容被截了",可以选择再读一次,而不是误以为文件就这么长。
run_write 的自动建目录:
python
fp.parent.mkdir(parents=True, exist_ok=True)
parents=True 自动创建多级父目录,exist_ok=True 目录已存在也不报错。一行代码解决"目录不存在"这个高频报错。
run_edit 的"只替换第一处":
python
content.replace(old_text, new_text, 1)
第三个参数 1 是关键------str.replace 默认替换全部匹配,传 1 就只替换第一个。为什么?因为 LLM 想改的是"某一段",如果这段文本在文件里出现多次,全替换可能误伤。"找不到就报错,不猜" 也是这个思路:
python
if old_text not in content:
return f"Error: Text not found in {path}"
注意这三个函数全部用 try/except 兜底,出错时返回字符串而不是抛异常。这是 Harness 的常见做法------错误信息本身就是要喂给 LLM 的上下文。告诉它"文件不存在",它就知道该先创建文件。
工具声明:给 LLM 看的说明书
python
CHILD_TOOLS = [
{
"type": "function",
"function": {
"name": "bash",
"description": "Run a shell command.",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string"}
},
"required": ["command"]
}
}
},
# read_file / write_file / edit_file 省略
]
PARENT_TOOLS = CHILD_TOOLS + [
{
"type": "function",
"function": {
"name": "task",
# 给子Agent 分配完全独立的上下文
"description": "Spawn a subagent with fresh context. It shares the filesystem but not conversation history.",
"parameters": {
"type": "object",
"properties": {
"description": {"type": "string", "description": "Short description of the task"},
"prompt": {"type": "string", "description": "The task for the subagent to complete"}
},
"required": ["prompt"]
}
}
}
]
这是今天最精妙的一处设计。
ini
CHILD_TOOLS = [bash, read_file, write_file, edit_file] ← 子 Agent 的工具
PARENT_TOOLS = CHILD_TOOLS + [task] ← 主 Agent 多一个 task
子 Agent 有四个文件操作工具,但没有 task------它不能再往下派发子 Agent,否则会无限套娃。
主 Agent 则多一个 task 工具,description 写得极具信息量:
"Spawn a subagent with fresh context. It shares the filesystem but not conversation history."
"shares the filesystem but not conversation history"------共享文件系统,但不共享对话历史。一句话讲清了 Sub Agent 的本质:
| 共享什么 | 不共享什么 |
|---|---|
| 文件系统(都在同一个 WORKDIR) | 对话历史(独立上下文) |
这正是 readme 说的"上下文互不干扰",也是"主 Agent 上下文不受拖累"的实现方式。
工具分发:一本花名册
python
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"])
}
字典映射,工具名 → 执行函数。注释还贴心地把 Python 的 **kw 跟 JS 对比了一下:
"**kw js ...kw rest 运算符"
**kw 把 LLM 返回的参数字典展开成关键字参数------{"command": "ls"} 变成 run_bash(command="ls")。跟 JS 的展开运算符 ...kw 是一个思路。
注意 read_file 用的是 kw.get("limit")------因为 limit 是可选参数(Schema 里 required 只有 path)。必填用 kw["x"],选填用 kw.get("x"),这个区分很重要。
子 Agent:独立的小办公室
python
def run_subagent(prompt: str) -> str:
sub_messages = [{"role": "user", "content": prompt}]
# 最多尝试30次
# 独立的Agentic Loop
for _ in range(30):
response = client.chat.completions.create(
model=MODEL,
# 子Agent 自己的历史必须带上,否则 prompt 根本没发给模型
messages=[{"role": "system", "content": SUB_SYSTEM}] + sub_messages,
tools=CHILD_TOOLS,
max_tokens=8000
)
msg = response.choices[0].message
sub_messages.append(msg.model_dump())
# OpenAI 协议里这个值是复数 tool_calls
if response.choices[0].finish_reason != "tool_calls":
break
results = []
for tool_call in msg.tool_calls:
func_name = tool_call.function.name
args = json.loads(tool_call.function.arguments)
handler = TOOL_HANDLERS.get(func_name)
output = handler(**args) if handler else f"Unknown tool {func_name}"
results.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": str(output)[:50000]
})
sub_messages.extend(results)
return msg.content or "(no summary)"
这段是 Sub Agent 的核心 ------注意它跟主循环长得几乎一样,因为它本身就是一个完整的、独立的 Agentic Loop。
几个关键设计:
1. 独立的上下文(fresh context)
python
sub_messages = [{"role": "user", "content": prompt}]
子 Agent 的对话历史从零开始------只有 main agent 传进来的那个 prompt。它看不到主 Agent 之前聊了什么,也不知道自己是被谁派来的。
这就是"业务隔离"------子 Agent 的上下文里没有主 Agent 的历史包袱。
代码注释还强调了一句:
"子 Agent 自己的历史必须带上,否则 prompt 根本没发给模型"
因为 LLM 是无状态的,sub_messages 必须在每轮请求里完整带上,否则子 Agent 会"失忆"。
2. 30 次上限
python
for _ in range(30):
注释说:
"最多尝试 30 次 / 下标我不用,占位置"
for _ in range(30) 里的下划线是 Python 惯例------"这个变量我不关心"。这 30 次是循环安全阀:防止子 Agent 陷入无限工具调用。
3. 终止条件
python
if response.choices[0].finish_reason != "tool_calls":
break
这就是 readme 说的"判断任务终止条件"------只要模型不再要求调工具,就说明它给出最终答案了,退出循环。
注释还提了个细节:
"OpenAI 协议里这个值是复数 tool_calls"
tool_calls 是复数------因为一次响应里 LLM 可能同时要求调多个工具。
4. 只返回结论
python
return msg.content or "(no summary)"
子 Agent 干了 30 轮活儿,最后只返回一段文本给主 Agent。中间那些工具调用细节、文件内容、报错信息------全部留在子 Agent 自己的上下文里,不给主 Agent 添乱。
or "(no summary)" 是兜底------万一模型返回空内容,也得给主 Agent 一个可读的结果,不然主 Agent 会困惑。
readme 对 Sub Agent 的描述在这里得到了完整验证:
"子 Agent 返回结果,有全新的上下文,不会干扰主 Agent 上下文。"
主循环:Agent 的心脏
python
def agent_loop(messages: list):
while True:
response = client.chat.completions.create(
model=MODEL,
messages=[{"role": "system", "content": SYSTEM}] + messages,
tools=PARENT_TOOLS,
max_tokens=8000
)
msg = response.choices[0].message
print(msg.content, "??")
messages.append(msg.model_dump())
if response.choices[0].finish_reason != "tool_calls":
return
results = []
msg = response.choices[0].message
if msg.tool_calls:
results = []
for tool_call in msg.tool_calls:
func = tool_call.function
args = json.loads(func.arguments)
# 主Agent分任务
if func.name == "task":
desc = args.get("description", "subtask")
prompt = args.get("prompt", "")
print(f"> task({desc}): {prompt[:80]}")
# 启动子Agent
output = run_subagent(prompt)
else:
# 主Agent 也可以自己干活
handler = TOOL_HANDLERS.get(func.name)
output = handler(**args) if handler else f"Unknown tool {func.name}"
results.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": str(output)
})
messages.extend(results)
这就是 Loop 模块------readme 说的"核心控制层,调用大模型,分发工具调用,判断任务终止条件",逐条对上:
| readme 描述 | 代码实现 |
|---|---|
| 调用大模型 | client.chat.completions.create(...) |
| 分发工具调用 | for tool_call in msg.tool_calls: |
| 判断任务终止条件 | if finish_reason != "tool_calls": return |
| 自主长时间干活 | while True: |
核心分支在 if func.name == "task":
python
if func.name == "task":
...
output = run_subagent(prompt) # 派活给子 Agent
else:
handler = TOOL_HANDLERS.get(func.name)
output = handler(**args) # 自己干
注释写得明白:
"主 Agent 分任务" / "启动子 Agent" / "只关注结果" / "主 Agent 也可以自己干活"
主 Agent 有两副面孔------简单活儿自己干(读个文件、跑个命令),复杂活儿派给子 Agent。这个"分派 or 自办"的决策完全由 LLM 自己根据任务性质决定,代码不干预。
这就是 Agent 和 Workflow 的分野------Workflow 里"谁来干"是写死的,Agent 里是 LLM 现场决定的。
两个 System Prompt:角色隔离
python
# 主Agent 系统提示
SYSTEM = (
f"You are a coding agent at {WORKDIR}."
"Use task for focused exploration or a self-contained subtask."
)
SUB_SYSTEM = (
f"You are a coding agent at {WORKDIR}."
"Complete the given task, then return a concise final answer."
)
readme 说的"专属 SYSTEM prompt",就是这两行:
| Prompt | 角色定位 | 关键指令 |
|---|---|---|
| SYSTEM | 主 Agent(规划、分工) | "Use task for focused exploration..." |
| SUB_SYSTEM | 子 Agent(执行任务) | "Complete the given task, then return a concise final answer." |
三处细节:
1. 用 f-string 把 WORKDIR 嵌进去
两个 prompt 都以 f"You are a coding agent at {WORKDIR}." 开头------告诉 LLM 自己在哪个目录。 这样它调 bash 和读写文件时不会迷路。
2. 主 Agent 被明确引导使用 task
主 SYSTEM 里那句 "Use task for focused exploration or a self-contained subtask." 是在主动引导 LLM 使用子 Agent------不写这句,模型可能什么都自己干,Sub Agent 就白设计了。
3. 子 Agent 被要求"简洁收尾"
"return a concise final answer"------子 Agent 干了多少活都行,但给主 Agent 的回复必须简洁。这是上下文管理的最后一道防线:子 Agent 自己的上下文可以很脏,但传给主 Agent 的必须是干净结论。
还有个 Python 语法彩蛋,注释特意标了:
"python 隐式字符串拼接,括号里连续放多个字符串变量"
f"You are..." "Use task..." 两个字符串写在一起,Python 会自动拼接成一个------不需要 + 号。这就是为什么 f-string 只加在第一个上,第二个却能共享 WORKDIR 插值的上下文。
交互界面:一间能进的办公室
python
if __name__ == "__main__":
print("Subagent - fresh messages, final text returns")
print("Enter a question, press Enter to sent. Type q to quit.\n")
history = []
while True:
try:
query = input("\001\033[36m\002s06 >> \001\033[0m\002")
except (EOFError, KeyboardInterrupt):
break
print(query)
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
一个极简的交互式 CLI:
history累积对话(这就是最基础的 Memory 模块------跨轮次保持在内存里)input()读用户输入q/exit/ 空行退出EOFError/KeyboardInterrupt优雅退出
那串 "\001\033[36m\002s06 >> \001\033[0m\002" 是 ANSI 转义序列------把提示符染成青色(\033[36m),\001 / \002 是 readline 库需要的边界标记(不加会导致光标位置计算错乱)。一个彩色提示符,藏着两个知识点。
注意 agent_loop(history) 是在主循环内部调用 的------每轮用户输入都调一次,而 history 一直在累积。这就是"多轮对话"的实现方式:Agent 的 Loop 是一层,用户交互的 Loop 是外面那层。
七、一张图收尾:六大模块在代码里的落点
scss
agent.py(338行)
│
├── Environment 沙箱 ────────── safe_path() + WORKDIR
│ 路径校验、工作区隔离
│
├── Policy & Safety ─────────── dangerous 黑名单
│ rm -rf / sudo shutdown 拦截
│
├── Tool 工具集 ─────────────── run_bash / run_read / run_write / run_edit
│ + CHILD_TOOLS / PARENT_TOOLS 声明
│
├── Loop 主循环 ─────────────── agent_loop() while True
│ ReAct:调用→分发→判断终止
│ run_subagent() 子 Agent 独立循环(30次上限)
│
├── Context 上下文管理 ───────── messages + SYSTEM + SUB_SYSTEM
│ out[:50000] 窗口控制
│ 子 Agent 只 return msg.content(压缩)
│
├── Memory 记忆层 ───────────── history 列表(跨轮次)
│ 子 Agent 的 sub_messages(独立上下文)
│
└── Observability ───────────── print(msg.content) / print(f"> task(...)")
(最简版:日志输出)
六个模块,一个不少,全在这 338 行里。 当然这是教学版本的极简实现------真正的生产级 Harness(比如你用的 Claude Code、Codex)在这六个方向上都有几十倍的工程量,但骨架是同一个。
八、给未来的自己:这份代码教会了什么
最后总结三层收获:
第一层:架构视角
Agent 不是"调用 LLM 的代码",而是六个模块协同的系统。Loop 是心脏、Tool 是手、Context 是记忆的调度台、Environment 是围栏、Memory 是笔记本、Observability 是监控。缺一个,"可靠、持续"就打折扣。
第二层:工程取舍
代码里处处是取舍------黑名单拦危险命令(简单但脆弱)、输出截断 5 万字符(防爆窗口但可能丢信息)、子 Agent 上限 30 轮(防死循环但可能不够用)。没有一个方案是完美的,都是权衡的结果。 这才是"Engineering"这个词的分量。
第三层:职业方向
readme 开头的两个岗位------"AI 应用开发工程师(Agentic RAG 应用)"、"AI Agent 开发工程师(Harness Agent FDE)"。学完这节课你会发现,这两个岗位的核心能力,不是"会调 API",而是能设计出上面这套系统。
PS:这节课学完,再看任何 Agent 产品,你都能拆成六个问题问它------你的主循环怎么设计的?工具集有哪些?上下文怎么压缩?沙箱怎么隔离?记忆存在哪?出问题怎么查?能答上来的,是认真做的产品;答不上来的,大概就是"套了个壳的聊天框"。