读完 DeepSeek Harness 源码,我给自己画了一张「最小 CLI Agent」施工图

引子:两个极端,和我想走的第三条路

想搞懂 Agent 到底怎么跑起来,网上给的路一般是两个极端。

一个是"30 行代码手写 Agent",你照着抄完,它能跑,但你不知道它为什么能跑------没有日志、没有并发、没有权限,一旦模型行为怪一点,你就只能靠 print 猜。

另一个是直接读一个成熟框架。比如 DeepSeek Harness(下称 DSH)开源的这个:Cordis 插件内核、20 多个工具、会话日志、沙箱、子 agent、PTY、遥测......读完你会觉得"太强了",然后更不敢自己写了。

我想要的是第三条:一条能自己解释清楚的最小 agent 循环。

复制代码
读输入 → 拼请求 → 流式收 → 装配 tool_call → 执行工具 → 结果进历史 → 再发一次

加上两件东西:一份事件日志 作为唯一真源,一道权限/沙箱作为闸门。就这些。

判断做没做成的标准也只有一条:任意一次对话,我都能从日志文件里重放出模型当时看到的每一个字节。

下面这份东西,是我通读 DSH 源码(MIT 协议,可以直接 clone)之后给自己列的施工图。所有"DSH 是怎么做的",都逐条核对过源码并标了 path:line,行号基于我读的那次 master 快照,仅供参考。


1. 先划边界,这一步比写代码重要

1.1 要做的(MVP 必备)

  • 单进程 REPL,流式输出,Ctrl-C 能取消当前这一轮
  • 一个 OpenAI 兼容的模型适配层(换个 baseURL 就换 provider)
  • 5 个工具:read / write / edit / bash / glob+grep
  • 追加式 JSONL 会话日志 + 从日志推导模型历史 + --resume
  • 三档权限模式 + 危险操作单次审批(fail-closed)
  • 输出截断 / 单条工具结果裁剪 / token 估算

1.2 明确不做的(写进 README,防止自己手痒)

不做 为什么现在不做
多 provider 抽象层 / 路由表 一个 OpenAI 兼容 adapter 就够,抽象留到第二个 provider 真出现时
插件系统 / Cordis 式组合 我不需要"替换 agent loop",我需要它先跑起来
会话自动压缩(compaction 摘要) 先靠「估算 + 硬截断 + 单条结果裁剪」顶住,M5 再说
MCP / skill / 子 agent / 工作流 全是乘数,没有主循环时毫无意义
TUI 框架 / 富文本渲染 ANSI 手写 20 行够了,别引入 textual / prompt_toolkit
向量记忆 / RAG 和"自用 CLI 助手"的日常任务无关
多轮失败自动反思 / 自我修复循环 先让单轮正确,再谈自动重试

1.3 一个反直觉的发现:最小 ≠ 安全

DSH 官方有个 sdk-minimal profile,是唯一不叠加 base 的完整树,只有 37 行。但它干了件很有意思的事:

它的 tool-fs 把 read/write/edit 全关掉 了(只留 readImage),文件操作用一个持久 bash 代劳;权限是 danger-full-access。

(packages/bundle/sdk-minimal/cordis.patch.yml:44-50, :73, :144)

也就是说,官方的"最小"是用安全性换来的------它砍到只剩一个 shell,然后在 shell 里裸奔。

这给了我第一个明确判断:别照抄。 我的版本应该是"工具多两个、权限多一道",代价大约是 150 行代码。


2. 七个决策,以及它们的反悔成本

做小项目最怕的不是选错,是选了一个三周后改不动的选项。所以每个决策我都标了"反悔成本"。

# 决策点 我的选择 理由 反悔成本
D1 语言 Python 3.11+,零第三方依赖 现有工具链是 Python;urllib + json + subprocess 就够;改一行就生效,没有构建步骤 低(约 1000 行)
D2 模型接法 OpenAI 兼容 /v1/chat/completions + SSE 我本机那个中转服务走的就是 openai-completions 协议,一个 adapter 能通吃 GLM / DeepSeek / 混元 低
D3 会话存储形状 追加式事件日志(JSONL,一行一事件),历史靠投影推导 这是 DSH 全部设计的根:deriveMessages() 从日志投影,绝不维护第二份消息数组 高(后期改等于重写)
D4 工具集规模 首批 5 个(read/write/edit/bash/glob) DSH 完整版 20+ 个工具,但 90% 的时间只用这 5 个 低
D5 并发默认 默认 exclusive;只有 read/glob/grep 标 safe;池上限 4 DSH 也只有 read/read_image/web_search/web_fetch/session_query 声明 safe,默认 10 并发(packages/core/tools/src/pipeline.ts:110-120) 低
D6 权限模型 三档 mode + 单次审批,fail-closed 见 §4 不变量 11 中
D7 上下文超限策略 先只做"裁剪",不做"摘要" 单条工具输出裁剪就能挡掉 80% 的爆窗(DSH 的 pruner 阈值 8192/4096/1024) 低

D3 值得单独说:为什么不能拿 messages 数组当状态

所有人写 Agent 的第一版都长这样:内存里维护一个 messages: list,每轮 append,退出时 dump 成 JSON。这个写法三天内绝对没问题,三周后你会同时遇到三个 bug:

  • --resume 之后模型看到的上下文和上次退出时不一样
  • 工具结果和 call_id 对不上,provider 直接 400
  • 想看"当时到底发了什么",只能去翻终端 scrollback

DSH 用一整节讲这句话(docs/architecture.md:128):

Model-visible means logged. 任何进入请求的东西,必须先是一个日志事件。

我的做法是把日志当数据库、把历史当视图:每次请求前,从事件流投影出 messages,投影完就扔,不缓存。多花 O(n) 的时间,换掉一整个 bug 类别。


3. 架构:10 个模块,约 1000 行

arduino 复制代码
                    ┌─────────────┐
   stdin ──────────►│  main.py    │ REPL / argparse / SIGINT
                    └──────┬──────┘
                           │ user 输入
                    ┌──────▼──────────────────────────────┐
                    │  agent.py   turn/step 状态机         │
                    │  ┌─────────────────────────────────┐ │
                    │  │ while: 组装→请求→装配→执行→记日志 │ │
                    │  └─────────────────────────────────┘ │
                    └──┬──────────┬──────────┬────────────┘
                       │          │          │
              ┌────────▼──┐  ┌────▼─────┐ ┌──▼─────────┐
              │ prompt.py │  │ llm.py   │ │ tools.py   │
              │ 系统提示   │  │ SSE+装配  │ │ 注册表+管线 │
              └───────────┘  └────┬─────┘ └──┬─────────┘
                                  │          │
                            ┌─────▼──────┐ ┌─▼────────┐
                            │  (网络)     │ │ batch.py │ 独占 barrier + 顺序提交
                            └────────────┘ │ policy.py│ 权限判定
                                           └─┬────────┘
                                             │
                                   ┌─────────▼─────────┐
                                   │ base_tools.py     │ read/write/edit/bash/glob
                                   └───────────────────┘
        ┌──────────────────────────────────────────────┐
        │ events.py   唯一真源:append / derive_messages │
        │             / JSONL 读写 / 崩溃尾部修复         │
        └──────────────────────────────────────────────┘
                      compact.py(M5 才加)
模块 行数 职责 关键约束
events.py ~150 SessionEvent(type, seq, time, data);append() 校验 + seq 连续;JSONL 追加写;derive_messages() 四条投影规则;崩溃尾部修复 唯一真源,其他模块不许缓存消息列表
agent.py ~180 turn/step 状态机、inbox(next-turn / next-step)、六类终止出口、失败补 tool result 不直接调工具,只调 tools / batch
llm.py ~140 SSE 解析、StreamChunk 四类增量、BlockAssembler、错误码归一 只做 bytes ↔ chunk,不做决策
tools.py ~150 注册表、JSON Schema 子集校验、管线 prepare → guards → dispatch → complete pre-execute 不允许改写 arguments
batch.py ~90 分类(exclusive/safe)、barrier、有界滚动池、按模型顺序提交、取消补偿 提交指针只能单调推进
policy.py ~70 三档 sandbox mode、workspace 前缀检查、ask 单次审批、never 策略 入口固定在 pre-execute 位置
base_tools.py ~140 read(行号+窗口+字节上限)、write(版本守卫)、edit(literal replace)、bash(超时+输出截断)、glob/grep 每个工具都返回结构化 error,不抛栈
prompt.py ~50 persona + 工具 schema 渲染成 system 文本,作为 surface 节点 0 落盘 系统提示不是"请求字段"
compact.py 0(M5)/ ~90 字符估算、阈值判断、摘要调用、插入 replace 事件 M5 之前就是个 pass
main.py ~80 argparse(-p 一次性 / --resume / --cwd / --mode)、REPL、SIGINT→cancel、退出前 flush ---

依赖方向必须单向:

matlab 复制代码
main → agent → {llm, tools, prompt} → {batch, policy} → base_tools
                   所有模块 → events(events 不 import 任何东西)

3.1 事件日志长什么样

python 复制代码
@dataclass
class SessionEvent:
    type: str          # turn/start | user | assistant | tool/call | tool/result | turn/end
    seq: int
    time: float
    data: dict

class SessionLog:
    def __init__(self, path):
        self.path, self.seq = path, 0

    def append(self, type_: str, **data) -> dict:
        self.seq += 1
        ev = {"type": type_, "seq": self.seq, "time": time.time(), "data": data}
        with open(self.path, "a", encoding="utf-8") as f:
            f.write(json.dumps(ev, ensure_ascii=False) + "\n")
        return ev          # 校验 + seq 连续 + flush,都在这里做

一行一事件,追加写,人眼可读。出事的时候 tail -f 就是调试器。

3.2 投影:历史是日志的视图

python 复制代码
def derive_messages(events: list[dict]) -> list[dict]:
    out = []
    for ev in events:
        t, d = ev["type"], ev["data"]
        if t == "user":
            out.append({"role": "user", "content": d["text"]})
        elif t == "assistant":
            if not d["blocks"]:          # 不变量 10:空 content 不投影
                continue
            out.append({"role": "assistant", "content": d["text"],
                        "tool_calls": d["tool_calls"]})
        elif t == "tool/result":
            out.append({"role": "tool", "tool_call_id": d["call_id"],
                        "content": d["content"]})
    return out

注意这段代码里没有排序 ------因为日志里 tool/result 本来就已经按模型顺序落盘了(见不变量 3)。"提交串行"这条约束的价值,在这里直接体现成一个更简单的投影函数。


4. 必须做对的 12 条不变量

这一节是整篇文章我最想写给自己的部分。下面每一条,都是 DSH 里被真实踩出来的坑,不是风格偏好。违反了就是 bug。

# 不变量 违反了会怎样 DSH 出处
1 tool/call 先落盘,再执行 ;tool/result 带 call_id 引用 崩溃后重放出"无主的 result",provider 报 dangling tool_result packages/core/tools/src/batch.ts:243-265
2 每个 assistant 的 tool_call 必须有配对 result:成功 / 失败 / 取消未派发,三种都要补 下一轮请求 400;模型看到残缺历史开始幻觉 packages/core/agent-loop/src/agent.ts:363-385、packages/core/session/src/repair.ts:105-178
3 结果按模型顺序提交,不是完成顺序 tool_use / tool_result 错位,模型把结果串到别的调用上 batch.ts:139-150(commitReady 只推进连续就绪的槽位)
4 并发安全默认 exclusive,只有显式声明 safe 才并行 write / edit 互相覆盖 packages/core/tools/src/pipeline.ts:110-120
5 流式装配:按 index 分组;工具参数靠拼接 argumentsDelta ;block-end 首次关闭胜出 工具收到半截 JSON / 文本重复 packages/llm/llm/src/assembler.ts:69-83
6 finish.kind == 'max-tokens' 时丢弃全部 tool_call 去执行一个参数是半截 JSON 的调用,副作用不可预期 assembler.ts:137-140
7 usage 在 finish 之前 发;finish 之后不得再有任何 chunk 账算不准 / 流关闭后写入报错 docs/subsystems/llm-streaming.md:298
8 空响应(stop 且无 content block)归成 EMPTY_RESPONSE 可重试错误 静默空转,turn 看起来正常结束但什么都没干 docs/subsystems/llm-streaming.md:304
9 任何进入请求的内容必须先是日志事件(含系统提示、注入上下文) resume 后历史与请求不一致 docs/architecture.md:128
10 空 content 的消息不投影进历史 (derive_messages 返回 None) 以为落盘了就进了历史,实际模型看不到 packages/core/session/src/surface.ts:135-141
11 审批只有 allowed-once 才算授权;缺 answerer / 超时 / 异常一律 deny 把"没人回答"当成 allow = 静默全放行 docs/subsystems/approval.md:21、pipeline.ts:444-492
12 write/edit 前必须读过该文件(版本守卫),版本失配报 stale 覆盖用户手改的内容 packages/fs/fs-observation-policy

4.1 最容易写错的是 5/6/7/8,都在流式那一层

装配器的核心就三件事:按 index 分组、参数拼接而不是赋值、关闭只认第一次。

python 复制代码
class BlockAssembler:
    def __init__(self):
        self.blocks: dict[int, dict] = {}
        self.closed: set[int] = set()

    def feed(self, chunk):
        i = chunk["index"]
        if i in self.closed:            # 不变量 5:block-end 先到者胜
            return
        b = self.blocks.setdefault(i, {"type": chunk["type"], "text": "", "args": ""})
        if chunk["kind"] == "text-delta":
            b["text"] += chunk["delta"]
        elif chunk["kind"] == "tool-call-delta":
            b["args"] += chunk["arguments_delta"]   # 拼接,不是 =
        elif chunk["kind"] == "block-end":
            self.closed.add(i)

然后在 finish 那一步做丢弃:

python 复制代码
tool_calls = assembled_tool_calls()
if finish.kind == "max-tokens":
    tool_calls = []      # 不变量 6:半截参数一律不执行
if finish.kind == "stop" and not blocks:
    raise LLMError("EMPTY_RESPONSE")   # 不变量 8:空响应当错误,不当成功

第 6 条我是从源码里"读到"的,但真正理解它是在自己想明白一件事之后:模型被截断时给的 tool_call,语法上可能完全合法 。{"path": "/etc/hos 是明显残缺的,但 {"path": "/etc"} 是完整 JSON------它只是不是你想要的。所以判据不能是"JSON 能不能解析",只能是"这一轮是不是被 max-tokens 掐断的"。

4.2 两条文档没写、但一定会咬人的

  • timeoutMs 写在工具定义里只是声明,管线不强制 (packages/core/tools/README.md:205)。→ 必须自己在 dispatch 层套 subprocess.run(timeout=...),否则一个 tail -f 就能挂死整个 agent。
  • turn 没有内置预算 ,死循环的唯一出口是你在 turn_stopping 处计数取消(packages/core/agent-loop/README.md:220)。→ 自己加 max_steps_per_turn。模型会非常有耐心地"再读一次同一个文件",直到你的钱包替它转圈。

5. 分阶段:每个里程碑都能独立跑起来

这是我认为最该学的一条工程习惯:不要按模块做,按"能跑起来的东西"做。

M0 · 骨架(约 2 小时)

一个能读 stdin、能打印、Ctrl-C 能退的壳。任务:main.py 的 REPL + argparse、events.py 的 append / JSONL 落盘、一个假的"回声模型"替身。

验收:python -m miniagent -p "hi" 打印 hi,并且 .miniagent/sessions/<id>.jsonl 里有 4 条事件(turn/start、user、assistant、turn/end)。

M1 · 真实模型 + 流式(1~2 晚)

单轮问答跑通,流式打印,无工具。任务:llm.py 的 SSE 客户端 + BlockAssembler(先只处理 text-delta 和 finish)、错误码归一(429/RATE_LIMIT、CONTEXT_WINDOW_EXCEEDED、EMPTY_RESPONSE)、prompt.py 生成 system 文本并落成节点 0。

验收:-p "用三句话解释什么是 SSE" 逐字流式输出;日志里 request/header 记下了 provider / model / contextWindow。 这一阶段最容易翻车:不变量 5、7、8。

M2 · 工具循环闭环(2~3 晚,核心)

模型能真的读文件、跑命令、改文件。顺序很重要:

  1. tools.py 注册表 + schema 渲染进请求
  2. base_tools.py:先只做 read + bash(最常用、最容易验证)
  3. assembler 补 tool-call-delta 装配
  4. batch.py:先纯串行 实现(不上池),但保留按顺序提交的骨架
  5. agent.py:step 循环 + 六类终止出口 + 失败补 result
  6. 再补 write / edit / glob

验收(全过才算完成):

  • -p "读一下 README.md,告诉我项目是干什么的" → 真的调了 read
  • -p "把 foo.py 里的 bar 改成 baz" → 调了 read 然后调了 edit
  • -p "运行 false 然后告诉我结果" → 错误作为 tool_result 回到模型,模型能继续,turn 不崩
  • 拔网线 / 杀进程后 --resume:历史完整,无 dangling tool_result

M3 · 自保(1~2 晚,最容易跳过但最该做)

目标很朴素:它不会在我睡着的时候删库。

任务:policy.py 三档 mode(read-only / workspace-write / danger-full-access)+ 路径前缀检查;ask 单次审批(stdin 一次问答,超时算 unavailable → deny);输出截断(单条工具结果 > 8192 字符裁成 head 4096 + tail 1024 + 截断提示);max_steps_per_turn;Ctrl-C → cancel(signal) 并给未派发调用补 ABORTED_BEFORE_DISPATCH。

验收:--mode workspace-write 下执行 -p "删除 ~/Documents 下所有文件" → 被 deny 且 turn 正常结束;--mode read-only 下 write 一律拒。

参照的 DSH 数值:pruner 的 thresholdChars: 8192 / headChars: 4096 / tailChars: 1024(packages/bundle/base/cordis.patch.yml:421-425);read 工具上限 2000 字符/行、50KB/次(packages/fs/tool-fs/src/read-render.ts:11,14)。

M4 · 体验(1 晚)

渲染(工具调用折叠成一行、diff 高亮)、/compact /new /model 斜杠命令、每轮结束打印 token 用量与累计花费、会话标题、--json 事件流(给脚本用)。

验收:日常不需要看原始日志,也能读懂它在干嘛。

M5 · 可选增强

compaction 摘要(阈值 0.8 × contextWindow,保留尾部 0.16)、重复调用软提醒(3/5/8 次)、子 agent、skill 目录、MCP。


6. 高频陷阱 Top 10(按"坑掉的时间"排序)

  1. 把 messages 数组当状态。写三周后发现 resume、取消、失败恢复全要改。→ 事件日志才是唯一真源。
  2. 工具调用参数拼接错 。argumentsDelta 是累加,不是赋值;content_block_delta 里的 input_json_delta 同理。
  3. tool_call 没有配对 result 。异常路径忘了写 result,下一轮直接 400。→ 用 try/finally 保证每个 call 都落地。
  4. 并发默认开着 。write 和 edit 同时跑。→ 默认 exclusive。
  5. 结果按完成顺序 append。模型把 A 的结果当成 B 的。→ 池子并发,提交串行。
  6. max-tokens 截断了还去执行工具 。参数是 {"path": "/etc/hos。
  7. usage 放在 finish 之后发。账算不准。
  8. 把空响应当成功。HTTP 200 但内容为空,用户看到"完成",实际什么都没变。
  9. 审批时"没人回答"当 allow 。等于静默全放行。→ 只认 allowed-once。
  10. 没加 max_steps_per_turn。模型卡在"再读一次同一个文件",你的钱包替它转圈。

7. 验收:怎么算"能自用了"

全打勾再宣布完工:

  • -p "..." 单次任务能跑完,退出码正确(0 完成 / 1 失败)
  • 流式输出逐字可见,不是等完整响应才打印
  • 能读、能改、能执行命令,且三个工具能在一次任务里串起来
  • 工具报错时模型能看到错误并自己纠正一次
  • Ctrl-C 立刻停,历史里留下 interrupted 标记,后续请求不炸
  • 杀进程 → --resume → 继续对话,模型看到的历史与中断前一致
  • 越界写(workdir 外)被拒绝,且拒绝原因写进日志
  • 单条超长输出被截断到阈值内,模型能感知"这里有截断"
  • 一次会话的日志文件能独立读懂(人眼可读的事件流)
  • 单轮步数上限触发时,明确告诉模型 / 用户,而不是静默卡死

8. 附录 A:DSH 的 minimal profile 到底挂了什么

那 37 行归纳成 12 类:应用外壳(2) / 官方遥测侧载(3) / 模型路由(1) / 模型运行时(2) / 会话日志(4) / agent 内核(3) / 工具注册表与执行器(2) / 提示词装配(1) / 沙箱与策略(2) / 文件附件工作目录(5) / 子进程与持久 shell(4) / 上下文与后台任务(6)。

  • 该抄的:会话日志四件套(事件日志 + 投影 + JSONL + checkpoint)、agent 内核三件套(agent / agent-loop / timer)、工具注册表与执行器的分离。
  • 不必抄的 :JSON-RPC 外壳、遥测侧载、MCP resources、image offload、session-title、jobs、持久 PTY(普通 subprocess 起一次 shell 就够)。
  • 反着抄的 :danger-full-access 默认值、tool-fs 全关、没有审批行。

DSH 有而我没有(并且我要清楚自己没有):compaction、spill(超过 12500 token 转存文件)、repeat-tool-reminder、timeout-policy、checkpoint barrier、OS 级沙箱(bwrap / Landlock / Seatbelt)。

前四个可以用 30 行粗糙替代,最后一个不能 。因为"路径前缀检查"根本挡不住 bash -c "cd / && rm -rf ~"。所以 workspace-write 模式下对 bash 应当始终 ask,而不是靠解析命令去猜意图。

这是我从这次读源码里得到的最重要的一条认知:你没有 OS 级隔离的时候,不要在应用层假装自己有。


9. 附录 B:一条 90 分钟的最小验证路径

如果不想一上来就写正式代码,先用一天里的 90 分钟写个一次性的 150 行脚本:

bash 复制代码
读 stdin → 拼一个 system + user → POST /v1/chat/completions (stream=true)
→ 逐块 print → 结束

跑通它你就知道:网络层没问题、模型能连上、SSE 到底长什么样。

然后把它扔掉,按 §3 的模块重写。

不要在那个 150 行脚本上迭代------它注定会变成一个没有日志、没有工具、没有权限、不可维护的东西。我见过太多"再改改就能用"的脚本死在这个阶段。


最后

写这份东西的过程里,最有价值的不是那 27 条任务,而是那 12 条不变量。任务清单会随着实现变化,而不变量是"你一旦搞错就得重写"的那一层。

如果只能带走一句话:

Agent 的复杂度不在循环里,在循环之外那些"模型看到的东西必须等于日志里的东西"的约束里。

(DSH 是 MIT 协议开源项目,本文所有源码引用都可以自己 clone 下来核对;行号基于我阅读时的 master 快照,版本变动后可能偏移。)

相关推荐
七夜zippoe2 小时前
Agent 错误处理体系:分类、重试、降级与熔断设计
ai·agent·降级·错误处理·熔断设计
武子康2 小时前
固定时间步已经开了,为什么游戏回放还会走出不同位置?
人工智能·后端·agent
EnCi Zheng2 小时前
Agent自动化系统怎么搭-从Super-agent架构到事件驱动调度的完整指南
自动化·agent·领导力·调度系统·super-agent
大模型真好玩3 小时前
DeepSeek Super-Agent核心特性详解与实战指南:把操作系统思想搬进智能体
人工智能·agent·deepseek
明月_清风3 小时前
为什么 Skill 很难商业化?Bot 才是 AI 时代真正的商业机会
人工智能·后端·agent
10年前端老司机3 小时前
Agent 商业化困局:热闹的发布会,冷清的付费客户
前端·后端·agent
合合技术团队3 小时前
企业级Agent服务平台选型指南:从需求匹配到场景落地,文档解析工具怎么选?
大数据·人工智能·agent·多模态文本智能技术·企业级agent服务平台
Sailing3 小时前
🚀🚀【封闭结束8】Agent RAG:从零搭建一个可用知识库
前端·后端·agent
前端双越老师3 小时前
程序员 1v1 访谈系列第十一期:应届生别做开发
java·agent·ai编程