← 城北 · chengbei.org 概览 三件套 运行 血肉 进阶 演进 速记
mini-harness · 自己搭一套 Agent harness

Agent = 大模型 + harness

模型负责「想」,harness 负责把想法变成现实。这门课一行行真代码把三件套 + 13 块积木搭出来——全部出自能跑的 index.js / src/,再拿它对照工业级 Claude Code。看得懂代码,才学得会怎么写。

发布于 2026-06-03
3件套核心
13块积木
~1180行真代码
真能跑
01

核心就三件套

harness 的全部骨架

一匹马力气很大,但不套上缰绳和马具,拉不了犁。大模型也一样——这套马具(工程上叫 harness)就是把它的智力引导成实际行动的那套装备。

① 缰绳 · The Loop
反复 问→执行→喂回
主循环 runAgent,一轮轮把模型的想法变成行动
② 工具 · The Tools
让模型能动手
一组工具 tools[]:读写文件、跑命令、搜索…
③ 护栏 · Permission
危险动作先问人
checkPermission 在执行前弹一句「允许吗」
①

缰绳 · The Loop

三件套 · 主循环 runAgent()
🔁
整个 harness 的心脏
反复 问→执行→喂回
把模型的「想法」变成一轮轮行动:问模型下一步干嘛 → 执行工具 → 把结果喂回去 → 再问。
  • 不再要工具就 return——这一轮的回答就是成果
  • 子 Agent 靠它递归:depth 防无限往下派
src/loop.js
src/loop.js · runAgent()
export async function runAgent(userInput, messages, depth = 0) {
  messages.push({ role: "user", content: userInput });
  while (true) {
    await compactIfNeeded(messages);          // ⑥ 太长先压缩
    const res = await withRetry(async () => {  // ⑩ 失败自动重试
      const stream = client.messages.stream({
        model: MODEL, system: buildSystemPrompt(),  // ④人设+⑦记忆
        messages, tools: toolSchemas,
      });
      stream.on("text", (c) => process.stdout.write(c)); // ⑤流式
      return await stream.finalMessage();
    });
    messages.push({ role: "assistant", content: res.content });
    if (res.stop_reason !== "tool_use") return finalText;  // 没工具→完成
    // …执行工具(②③⑧⑨⑫),把结果喂回 messages,进下一轮…
  }
}
②

工具 · The Tools

三件套 · 工具数组 tools[]
🛠️
让模型能动手
说明书 + run() + 标记
每个工具 = 给模型看的「说明书」(name/description/input_schema) + 真正干活的 run() + 危险与否的 needsApproval。
  • 模型只看说明书自己决定调谁,工具负责执行
  • 8 个工具:读写 / 跑命令 / 建目录 / 搜索 / 子 Agent
src/tools.js
src/tools.js · 一个工具的结构
{
  name: "write_file",
  description: "把内容写入文件(会覆盖原文件)",
  input_schema: {
    type: "object",
    properties: { path: { type: "string" },
                  content: { type: "string" } },
    required: ["path", "content"],
  },
  needsApproval: true,   // 写文件有风险,交给护栏问人
  run: ({ path, content }) => {
    writeFileSync(path, content);
    return `已写入 ${path}(${content.length} 字符)`;
  },
}
③

护栏 · Permission

三件套 · checkPermission()
🛡️
危险动作先问人
把决定权交回人手里
模型再聪明也可能「想删 README」。护栏在危险工具真正执行前拦一道。
  • 先查规则表 allow/deny(见⑨),再问人
  • y/n/a 三档,a = 以后都放行记住选择
src/permission.js
src/permission.js · checkPermission()
export async function checkPermission(tool, input) {
  const action = matchRule(tool.name, input);   // ⑨ 先查规则表
  if (action === "allow") return true;       // 自动放行
  if (action === "deny")  return false;      // 直接拒
  if (action === null && !tool.needsApproval) return true;
  if (sessionAllowlist.has(tool.name)) return true; // 记住过
  const ans = await ask(`允许吗? (y / n / a=以后都允许) `);
  const c = ans.trim().toLowerCase();
  if (c === "a") { sessionAllowlist.add(tool.name); return true; }
  return c === "y";
}
02

它真能跑 · 台架实测

node demo-offline.js · 不花钱、假模型 + 真工具真护栏
$ node demo-offline.js
🔌 [MCP] 从 "local-env-info" 发现并注册了 2 个外部工具
  ✅ [规则] 自动放行:git status → action=allow
  ⛔ [规则] 直接拒绝:sudo … → action=deny
  ✅ [演示] 回答 a(以后都允许)→ 记住 write_file 本会话放行
  🛡️ [Hook 拦截] 危险命令被硬拦:命令 "rm -rf ." 匹配毁灭性模式
  🔀 [编排] 本轮 3 个只读工具并发、0 个写/危险工具串行
  ✅ node_version → v24.1.0      📋 [审计] 调用了 git_current_branch
  ✅ 存取闭环验证通过:存进去 23 条、读回来 23 条,内容一致

一条命令,把三件套 + 护栏硬拦 + 规则放行 + 并发编排 + MCP 发现 + 会话存取全转一遍——真读目录、真拦 rm、真写盘。

④

人设 · System Prompt

三件套之上的血肉 · 第 1 块
🎭
给它「你是谁」
行为立刻变稳
每轮对话最开头喂一段「你是谁 + 行为准则」,Agent 立刻先说再做、会总结——CLAUDE.md 的最小原型。
  • 改这段话,Agent 行为立刻不一样
  • ⑦记忆 也拼在这里:人设 + 长期记忆
src/prompt.js src/memory.js
prompt.js + memory.js
// prompt.js —— 你是谁
export const SYSTEM_PROMPT = `你是一个谨慎、靠谱的命令行助手。
行为准则:
1. 动手前先说清楚你打算做什么,再调工具。
2. 涉及写文件 / 删除 / 跑命令,明确告诉用户风险点。`;

// memory.js —— 每轮把「人设 + 长期记忆」拼起来喂模型
export function buildSystemPrompt() {
  const memory = loadMemory();              // ⑦ 读 memory.md
  if (!memory) return SYSTEM_PROMPT;
  return `${SYSTEM_PROMPT}\n\n## 你的长期记忆\n${memory}`;
}
⑤

流式输出 + 子 Agent

血肉 · 一字一字冒出来 / 派活给分身
🌊
流式 · 子 Agent
边想边说 · 起个分身
流式:每蹦一小块字就立刻打印,体验像真人打字。子 Agent:把独立子任务派给全新 Agent,互不污染上下文。
  • 流式实测一次回答分 80+ 块陆续到达
  • 子 Agent 用 depth+1 递归,防无限套娃
src/loop.js
src/loop.js · spawn_agent 分支
// ⑤ 流式:每蹦一块就立刻打印
stream.on("text", (c) => process.stdout.write(c));

// 子 Agent:派活给一个全新的分身
} else if (block.name === "spawn_agent") {
  if (depth >= 2) {
    resultText = "已达最大深度,不能再往下派";
  } else {
    const subMessages = [];            // 全新独立上下文
    resultText = await runAgent(block.input.task,
                       subMessages, depth + 1); // 递归
  }
}
⑥

上下文压缩

进阶 · 分水岭 · compactIfNeeded()
🗜️
对话太长就崩
揉成一段摘要
问题:对话越长,每轮塞回的历史越多、token 撞墙就崩。对策:旧消息调模型总结成摘要,splice 替换原数组。
  • 保留最近几条原文 + 一段摘要顶替旧的
  • 对照 Claude Code 的 /compact
src/compact.js
src/compact.js · compactIfNeeded()
export async function compactIfNeeded(messages) {
  if (messages.length <= MAX_HISTORY) return;  // 没超就不动
  let keepStart = Math.max(0, messages.length - KEEP_RECENT);
  const oldMessages = messages.slice(0, keepStart);
  const recentMessages = messages.slice(keepStart);
  // …把 oldMessages 转成文本,调模型总结成 summary…
  const summaryMsg = { role: "user",
    content: `【以上对话摘要】\n${summary}` };
  messages.splice(0, messages.length,        // 原地替换
                  summaryMsg, ...recentMessages);
  console.log(`📦 已把前面 ${oldMessages.length} 条压成摘要`);
}
⑦

跨会话记忆

进阶 · 分水岭 · memory.md + remember
🧠
关掉就忘
写进 memory.md
经验写进本地 memory.md,每轮加载进人设;配 remember 工具让 Agent 主动记。关掉重开还记得。
  • 对照 Claude Code 的 memdir / SessionMemory
  • 从「这次对话」到「长期成长」
src/memory.js src/tools.js
memory.js + tools.js(remember)
// memory.js —— 读出长期记忆(读不动也不崩)
export function loadMemory() {
  try {
    if (!existsSync(MEMORY_FILE)) return "";
    return readFileSync(MEMORY_FILE, "utf8").trim();
  } catch { return ""; }
}

// tools.js —— remember 工具:往 memory.md 追加一行
run: ({ note }) => {
  appendFileSync(MEMORY_FILE,
    `- [${new Date().toLocaleString("zh-CN")}] ${note}\n`);
  return `已记住:${note}`;
},
⑧

钩子 · Hooks

进阶 · 分水岭 · preToolHooks
🪝
比护栏更前置
有些事根本不该问
工具执行前后插「钩子」:危险命令硬拦(连问都不问)、写文件前自动备份、事后审计。
  • 顺序:钩子硬拦 → 护栏问人 → 执行 → 审计
  • 对照 Claude Code 的 hooks/(40+ 类型)
src/hooks.js
src/hooks.js · dangerousCmdHook
const dangerousCmdHook = (toolName, input) => {
  if (toolName !== "run_bash") return null;
  const cmd = String(input.command || "");
  const dangerous = [
    /rm\s+-rf?\s+\/[^a-z]/i,   // rm -rf /
    /rm\s+-rf?\s+\./i,         // rm -rf .
    /mkfs\s*\./i, /dd\s+if=/i,    // 格式化 / 覆写磁盘
  ];
  for (const re of dangerous)
    if (re.test(cmd)) return { block: true, reason: "毁灭性命令" };
  return null;   // 放行(再交给护栏问人)
};
export const preToolHooks = [dangerousCmdHook, backupBeforeWriteHook];
⑨

权限深化

进阶 · 分水岭 · permissionRules
🪪
y/n 太粗糙
升级成规则表
把「每次都问」升级成规则表:按工具+参数匹配 allow / ask / deny,精确在前、宽泛兜底。
  • 只读 git 自动放行、sudo 直接拒
  • 对照 Claude Code 的 utils/permissions/
src/permission.js
src/permission.js · permissionRules
export const permissionRules = [
  { tool: "run_bash",
    match: (i) => /^\s*git\s+(status|diff|log)\b/.test(i.command),
    action: "allow" },          // 只读 git 自动放行
  { tool: "run_bash",
    match: (i) => /\bsudo\b/.test(i.command),
    action: "deny" },           // sudo 直接拒,连问都不问
];
export function matchRule(toolName, input) {
  for (const rule of permissionRules) {     // 从头扫,第一条命中即返回
    if (rule.tool !== toolName) continue;
    if (rule.match && !rule.match(input)) continue;
    return rule.action;
  }
  return null;
}
⑩

容错重试

进阶 · 更稳更强 · withRetry()
🎯
API 抖一下别跟着崩
指数退避重试
网络会抖、会限流。包一层重试:失败按 0.5s→1s→2s 翻倍等待(+随机抖动),4xx 立刻抛不浪费。
  • 429/超时可重试,401 鉴权错立刻抛
  • 对照 services/api/withRetry.ts
src/retry.js
src/retry.js · withRetry()
export async function withRetry(fn, opts = {}) {
  const { retries = 3, baseDelay = 500,
          isRetriable = isRetriableError } = opts;
  for (let attempt = 0; attempt <= retries; attempt++) {
    try { return await fn(); }
    catch (err) {
      if (attempt >= retries || !isRetriable(err)) throw err;
      const delay = baseDelay * Math.pow(2, attempt)
                  + Math.floor(Math.random() * 100);  // 指数退避+抖动
      await new Promise((r) => setTimeout(r, delay));
    }
  }
}
⑪

会话持久化

进阶 · 更稳更强 · saveSession / loadSession
💾
关掉就没了
存 jsonl,--resume 续聊
messages 每条序列化成一行 jsonl 写盘;下次 --resume 读回接着聊。坏一行不毁全文。
  • 坏行跳过不崩,读不动返回空数组
  • 对照 utils/sessionStorage.ts
src/session.js
src/session.js
export function saveSession(messages) {
  const jsonl = messages.map((m) => JSON.stringify(m)).join("\n");
  writeFileSync(SESSION_FILE, jsonl, "utf8");     // 每条一行
}
export function loadSession() {
  if (!existsSync(SESSION_FILE)) return [];
  const raw = readFileSync(SESSION_FILE, "utf8").trim();
  return raw.split("\n").reduce((acc, line) => {
    try { acc.push(JSON.parse(line)); }   // 坏行跳过,不崩
    catch {}
    return acc;
  }, []);
}
⑫

工具编排

进阶 · 更稳更强 · 只读并发 / 写串行
🔀
能并发的别排队
只读并发,写串行
同一轮多个工具:只读的 Promise.all 并发跑,写/危险的串行,最后按原顺序重组结果。
  • spawn_agent 会问人,强制串行
  • 对照 services/tools/toolOrchestration.ts
src/loop.js
src/loop.js · 分桶调度
const isReadOnly = (b) =>
  toolMap[b.name]?.needsApproval === false &&
  b.name !== "spawn_agent";          // 会问人,不并发

const readOnly = toolUseBlocks.filter(isReadOnly);
const serial   = toolUseBlocks.filter((b) => !isReadOnly(b));

// 只读组:并发;写组:串行
const reads = await Promise.all(readOnly.map((b) => runOneTool(b, depth)));
for (const b of serial) results.push(await runOneTool(b, depth));

// 按原始顺序重组(否则喂回的顺序错乱→API 报错)
const toolResults = toolUseBlocks.map((b) => resultMap.get(b.id));
⑬

MCP / 多 Agent

进阶 · 更稳更强 · loadMcpTools(最小示意)
🔌
工具写死太死
外部即插即用
外部「工具服务器」声明有哪些工具,启动时动态发现注册进 tools——主循环一行不改,能力却扩展了。
  • + Coordinator 拆任务并行派多个子 Agent
  • 对照 Claude Code 的 services/mcp/
src/mcp.js
src/mcp.js · loadMcpTools()
export const localMcpServer = {
  name: "local-env-info",
  tools: [
    { name: "node_version", cmd: "node --version", /*…*/ },
    { name: "git_current_branch", cmd: "git branch --show-current" },
  ],
};
export function loadMcpTools(server, tools) {
  for (const t of server.tools)
    tools.push({ name: t.name, description: t.description,
      needsApproval: false,
      run: () => execSync(t.cmd).toString().trim() });  // 包成内部工具
  console.log(`🔌 [MCP] 发现并注册了 ${server.tools.length} 个外部工具`);
}
03

一块块 commit 搭出来

不是一次写好,而是一个功能一个提交
5fdf384 refactor: index.js 拆成 src/ 12 个职责模块
34e7186 feat: 加 MCP/多Agent 最小示意(积木⑬)
b8d1cc6 feat: 加工具编排(积木⑫)只读并发/写串行
9fd1c45 feat: 加会话持久化(积木⑪)jsonl + --resume
80d69f4 feat: 加容错重试(积木⑩)指数退避
7fdbcf0 feat: 加权限深化(积木⑨)规则表 allow/ask/deny
dfb1348 feat: 加 Hooks 钩子(积木⑧)危险命令硬拦
a2753c5 feat: 加跨会话记忆(积木⑦)memory.md
2738cb2 feat: 加上下文压缩(积木⑥)揉成摘要
f37dd2c feat: 加子 Agent(积木⑤·最难的一块)
0693a2d feat: 加更多工具 + 结果截断(积木④)
4db287e feat: 加流式输出(积木③)
c183685 feat: 加 system prompt 人设(积木②)
4dd4459 feat: mini-harness 初始版本(积木①)150 行最小 Agent

每块积木 = 一个独立 commit,git checkout 到任意节点,就能看「讲到这一块时代码长什么样」——学员照着一块块学。

⑭

模块化重构

收尾 · 1198 行单文件 → 100 行入口 + src/ 12 模块
🧱
一坨拆成十二块
瘦入口 + src/ 职责模块
功能全搭完后,把 1198 行的 index.js 拆成 100 行瘦入口 + src/ 12 个职责模块。功能一行没改,纯粹让代码能读、能找、能扩。
  • 每个模块刻意对标 Claude Code 真源码的一个目录
  • ESM 单向依赖、无循环(拆模块最容易踩的坑)
  • 「怎么把一坨代码模块化」本身就是教学内容
index.js src/
src/ 模块 ↔ Claude Code 真源码目录
loop.js→query/
tools.js→tools/
permission.js→utils/permissions/
compact.js→services/compact/
hooks.js→hooks/
memory.js→memdir/
mcp.js→services/mcp/
retry.js→services/api/withRetry
session.js→utils/sessionStorage
config·prompt·io→api · prompts · terminal

你的 ~1180 行 ↔ Claude Code 51 万行

同一套骨架,工业级只是每块换成更强实现 + 一层「把 React 渲染进终端」的界面。看懂这 ~1180 行,再翻 51 万行,你认得出每一块。

主循环
你的 while + 工具喂回 ↔ 它的 while 状态机 + 内嵌容错恢复
工具
你的 8 个对象 ↔ 它的几十个 + 统一接口 + 沙箱
护栏
你的 y/n + 规则表 ↔ 它的多源规则 + AI 命令分类器
上下文
你的 揉成摘要 ↔ 它的 4 层压缩 + 双记忆分层预算
界面
你的 readline 问答 ↔ 它的 Ink 终端 React + 自研 Yoga
本质
大厂 Agent 都是这同一套——只是工具更多、护栏更细、循环更稳
按 P 切换投影一屏一页模式
chengbei.org · 2026-06-03