第 02 章 · 30 行手写一个最小 Harness
本篇目标:不依赖任何 Agent 框架,跑通一次完整的「模型 → 工具 → 回填 → 自停」。 写完这一章,后面 17 章都是在给这 30 行加东西。
2.1 先去掉神秘感
所有 Agent Harness 的内核都是同一个循环:
while 未完成:
response = call_model(messages) # 模型输出:文本 或 工具调用
append(response)
if response 没有工具调用: break # 停止条件
for each tool_call:
result = execute(tool_call) # 现实世界发生副作用
append(tool_result) # 把结果回填给模型
DSPy、LangGraph、Claude Code、DSH 的差别,全在这五行之外:工具怎么注册、状态怎么持久化、上下文怎么压缩、执行怎么隔离、失败怎么恢复。内核没有第二种写法。
官方 DSH docs/architecture.md 的 turn flow 是同一结构的形式化版本:一个 step = 一次模型请求 + 它调用的工具;一个 turn = 零到多个 step,直到「nothing is owed」才关闭。
2.2 可运行的最小实现
零第三方依赖,只用 Python 标准库。设置 DEEPSEEK_API_KEY 后可直接运行。
# mini_harness.py —— 零依赖。DEEPSEEK_API_KEY=<key> python mini_harness.py
import json, os, subprocess, urllib.request
from pathlib import Path
ENDPOINT = "https://api.deepseek.com/chat/completions"
MODEL = "deepseek-chat"
ROOT = Path.cwd().resolve() # 最小沙箱:所有文件操作限制在此目录
SYSTEM = "You are a helpful software engineer assistant. 用工具完成任务,完成后直接给结论。"
TOOLS = [{
"type": "function",
"function": {
"name": "read_file",
"description": "读取工作区内一个文本文件的完整内容",
"parameters": {"type": "object", "properties": {
"path": {"type": "string", "description": "相对工作区的路径"}
}, "required": ["path"]},
},
}, {
"type": "function",
"function": {
"name": "write_file",
"description": "把内容写入工作区内一个文件(覆盖)",
"parameters": {"type": "object", "properties": {
"path": {"type": "string"}, "content": {"type": "string"}
}, "required": ["path", "content"]},
},
}]
def call_model(messages):
payload = json.dumps({"model": MODEL, "messages": messages,
"tools": TOOLS, "tool_choice": "auto",
"temperature": 0}).encode()
req = urllib.request.Request(ENDPOINT, payload, headers={
"Authorization": f"Bearer {os.environ['DEEPSEEK_API_KEY']}",
"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as r:
return json.load(r)
def safe_path(rel):
p = (ROOT / rel).resolve()
if not p.is_relative_to(ROOT): # 最小但真实的边界,挡住 ../ 越界
raise PermissionError(f"越界访问: {rel}")
return p
def dispatch(name, args):
if name == "read_file":
return safe_path(args["path"]).read_text(encoding="utf-8")
if name == "write_file":
safe_path(args["path"]).write_text(args["content"], encoding="utf-8")
return "OK"
return f"未知工具: {name}"
def run(task, max_steps=12):
messages = [{"role": "system", "content": SYSTEM},
{"role": "user", "content": task}]
for step in range(1, max_steps + 1):
resp = call_model(messages)
msg = resp["choices"][0]["message"]
usage = resp.get("usage", {})
print(f"[step {step}] in={usage.get('prompt_tokens')} out={usage.get('completion_tokens')}")
messages.append(msg)
calls = msg.get("tool_calls") or []
if not calls: # ← 唯一的停止条件
return msg["content"]
for c in calls: # 串行;只读调用本可并行,见 2.4
fn = c["function"]
try:
out = dispatch(fn["name"], json.loads(fn["arguments"] or "{}"))
except Exception as e:
out = f"ERROR: {type(e).__name__}: {e}" # 错误回填,让模型自纠
messages.append({"role": "tool", "tool_call_id": c["id"], "content": out})
return "达到 max_steps 上限(疑似死循环)"
if __name__ == "__main__":
print(run("读 README.md,写一份 3 行摘要到 SUMMARY.md"))
2.3 逐行拆解:五行之外的六个工程决策
| 代码位置 | 决策 | 对应六要素 | 去掉会怎样 |
|---|---|---|---|
TOOLS 里的 description |
工具描述会进系统提示词,是提示词的一部分 | Tools / Context | 模型选错工具或不会用 |
temperature: 0 |
执行型任务优先确定性 | Loop | 同类任务结果漂移 |
max_steps=12 |
硬停止条件 | Guardrails | 一次失误烧穿预算 |
safe_path().is_relative_to(ROOT) |
强制边界,而非在系统提示词里写「请只读」 | Sandbox | 一条 ../ 就能越界 |
except 后把错误回填 |
把失败信号送回模型上下文 | Sensors | 模型不知道失败,继续错下去 |
usage 打印 |
最小可观测性 | Sensors | 无法量化任何改进 |
判据 4(强制 vs 建议):在系统提示词里写「不要修改工作区外的文件」是建议;
is_relative_to抛异常是强制。凡是需要 100% 生效的约束,必须由代码执行,不能由模型自觉遵守。第 08 章把这条推到极致。
这也解释了 官方 SAFETY.md 为什么直说「沙箱、审批、权限控制能降低风险,但不保证隔离」。上面这个 safe_path 只防路径穿越,防不住符号链接、write_file 覆盖 .env、以及子进程联网。边界的强度取决于你实际实现了多少,不取决于你声明了多少。
2.4 逐步加料:从 30 行到生产级
按此顺序叠加,每一步都对应本系列一章。不要跳步——每加一层,都要能单独验证它修掉了哪一类失败。
| 步骤 | 加什么 | 修掉的失败 | 对应章 |
|---|---|---|---|
| 1 | 把 messages 追加写 JSONL |
进程崩了无法复盘 | 03 |
| 2 | 只读工具并发(concurrent.futures,上限 10) |
批量任务慢 | 09 |
| 3 | 工具 schema 拆到独立文件,按需加载 | 系统提示词膨胀 | 09 / 10 |
| 4 | PreToolUse 校验 + Stop 前跑测试 |
规则被静默跳过 | 08 |
| 5 | 上下文超 80% 时压缩中段 | 长任务撑爆窗口 | 10 |
| 6 | 每次 model / tool call 记 trace + duration | 线上问题无证据 | 16 |
| 7 | side effect 前插人工审批 | 误删、误发、误付 | 16 |
| 8 | 子进程移到容器,默认 deny 网络 | 提示注入导致外联 | 17 |
| 9 | 固定题面 + 重置环境 + eval suite | 「感觉变好了」无法证伪 | 15 |
| 10 | 多 Agent + worktree 隔离 + 任务板 | 并发改同一文件互相踩 | 18 |
2.5 与 DSH 的对照
官方 docs/architecture.md 描述的真实 turn 流程(缩写):
turn/start
claim next-step input + queued message
snapshot model selection; assemble prompt sections + step-admitted tool schemas
-> agent/pre-step # 可改写/拒绝本轮输入
step/start
agent/request -> prepareCall # 绑定模型适配器,取消则不提交任何事
stream -> llm/stream -> assistant/stream
tool/call* -> tools/pre-execute -> tools/execute -> tools/post-execute -> tool/result*
step/end
tools owe another request, or input arrived -> next step
-> agent/turn-stopping
turn/end
对照我们的 30 行:
| mini_harness | DSH | 多出来的价值 |
|---|---|---|
messages 列表 |
append-only SessionEvent 日志 + deriveMessages() |
可 fork / resume / replay / 审计 |
call_model 直连 |
ctx.llm 适配器接缝 |
换模型不改 loop |
dispatch 硬编码 |
ctx.tools 注册表 + tools/* 三级流水线 |
工具可热插拔、可拦截 |
错误 try/except |
durable tool/result 事件 + 失败 attempt 保留 |
重试/取消可追溯 |
max_steps |
agent/turn-stopping 串行事件 |
插件可自定义停止策略 |
这一章真正的结论:框架不提供新能力,框架提供接缝(seam)。DSH 把每个接缝都做成可替换插件,于是「加能力」不再需要改内核。第 11 章解释这在数学上为什么成立。
2.6 本章产出与验收
核心产出:mini_harness.py 跑通一次真实任务(读文件 → 写文件 → 自停),并保留一份 JSONL 运行日志。
验收点:
- 去掉
max_steps后能人为造出一次死循环,并说清它烧了多少 token - 把
safe_path注释掉,用../让模型写出工作区外文件,理解「建议 ≠ 边界」 - 故意让一个工具返回错误,观察模型是否自纠;不自纠则思考错误信息该怎么写
- 能对着日志指出:哪几行是 durable 事实,哪几行只是进程内状态
延伸阅读
| 来源 | URL | 等级 |
|---|---|---|
| DSH 架构文档 · Turn flow | https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/architecture.md | 官方 |
| 官方文档 · Agent 生命周期 | https://deepseek-harness.github.io/deepseek-harness/reference/agent-lifecycle | 官方 |
| DeepSeek API 文档 | https://api-docs.deepseek.com/ | 官方 |
| Anthropic, Effective harnesses for long-running agents | https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents | 社区 |