欢迎光临
我们一直在努力

Day65|从0学习 Claude Code(十五):Harness 集成,14 章的零件装回同一台车

苦猿的大模型日记 · Day65 · 从0学习Claude Code(十五)Harness集成-帮普通人把AI学进简历系列

前言:零件全有了,车还跑不起来

本篇干一件事:不引入任何一个新机制,把前面攒下的所有零件——工具分发、权限、Hooks、todo、子 agent、技能、记忆、上下文压缩、后台任务、定时调度、团队、worktree、MCP——全部装回同一个 while True。

先说实话,这是整个系列最难的一篇。难的不是新知识,是集成。

那些零件单独都会跑:权限示例能拦命令,记忆示例能存偏好,定时示例能到点触发。可真把它们倒进同一个工程,问题立刻排队上门:

  • cron 到点的提醒,怎么送进正在跑的对话?
  • 后台任务完成了,谁来告诉 Agent?
  • 队友发来的 plan,排在用户问题前面还是后面?
  • 上下文压缩和记忆注入,谁先谁后?
  • 用户正在敲键盘,定时任务到点了,两轮对话撞车怎么办?
  • 每一个单拎出来都不难,难的是它们共享同一段对话、同一个工具池、同一份上下文预算——动任何一个,其他全部跟着晃。

    读完这篇,你会拿到三样东西:

  • 一张全景图:十几种机制,各自挂在循环的哪个位置
  • 逐段读懂主循环的能力:一轮从进门到出门,到底发生了什么
  • 事件驱动机制:Agent 跑完一轮就睡了,是谁把它叫醒的
  • 门槛不变:一路跟到现在的读者直接开跑;新来的朋友只需要记住一句话——Agent 循环 = 调模型 → 看响应里有没有工具调用 → 有就执行、结果回消息。

    一个循环,四类挂点


    PART 01:集成第一决策——循环一个都不多

    先看最反直觉的地方。这个集成版的 Agent,工具从最早的 5 个涨到了 26 个,机制从 0 涨到了 13 种,但主循环的结构一行没变:

    调用模型
    → 响应里有 tool_use block?
    没有 → 本轮结束
    有 → 执行工具 → 结果追加回 messages → 下一轮

    还是那三步。这不是偷懒,是整个集成方案里最重要的一条纪律:循环结构不许动。

    为什么?因为循环是全系统的主干道。每加一个机制就改一次循环,改到第十次,循环会变成一坨谁也不敢碰的面条——每个机制都认识其他所有机制,牵一发动全身。正确的做法反过来:循环不认识任何机制,机制自己找地方挂。

    于是整个系统只有四类挂点:

    • LLM 前:定时任务、后台通知、压缩管线、记忆和技能组装——把"该带的行李"塞进上下文
    • 工具执行前:PreToolUse Hooks + 权限——把"不该执行的动作"拦下来
    • 工具执行后:PostToolUse Hooks——大输出告警、日志这些后处理
    • 本轮收尾:没有 tool_use、循环要退出时,Stop Hooks 做统计和清理

    十几种机制,四类挂点。就像装修房子不用砸承重墙——水管走水管的位置,电线走电线的位置,房子还是那个房子。

    机制可以无限加,循环永远是那一个。 这是集成全部设计的前提。


    PART 02:主循环逐段拆解——一轮到底发生了什么

    把主循环的代码简化后摆出来,这是全篇的主角:

    def agent_loop(messages, context, active_request):
    tools, handlers = assemble_tool_pool()
    while True:
    # ① 到点的定时任务,作为 user 消息注入对话
    for job in consume_cron_queue():
    messages.append({"role": "user",
    "content": f"[Scheduled] {job.prompt}"})

    # ② 后台任务完成的通知,同样注入对话
    inject_background_notifications(messages)

    # ③ 连续三轮没碰 todo,提醒它更新计划
    if rounds_since_todo >= 3:
    messages.append({"role": "user",
    "content": "<reminder>Update your todos.</reminder>"})

    # ④ 压缩管线 + 组装 system prompt 和工具池
    prepare_context(messages, active_request)
    context = update_context(context, messages)
    tools, handlers = assemble_tool_pool()

    # ⑤ 调模型(外面包着错误恢复)
    response = call_llm(messages, context, tools, state, max_tokens)
    messages.append({"role": "assistant", "content": response.content})

    if not has_tool_use(response.content):
    trigger_hooks("Stop", messages) # 没有 tool_use,本轮结束
    remember_after_turn(messages) # 提取记忆
    return

    # ⑥ 逐个执行工具,结果回 messages
    results = []
    for block in response.content:
    if block.type != "tool_use":
    continue
    blocked = trigger_hooks("PreToolUse", block) # 权限在这拦
    if blocked:
    results.append(tool_result(block.id, blocked))
    continue
    output = call_tool_handler(handlers[block.name], block.input)
    trigger_hooks("PostToolUse", block, output)
    results.append(tool_result(block.id, output))
    messages.append({"role": "user", "content": results})

    逐段看。

    ①② 是全篇最值钱的一个设计:cron 到点的提醒、后台任务的完成通知、队友发来的消息——统统变成 messages 里一条 role: "user" 的内容。

    不管事件从哪来(定时器、后台线程、队友收件箱),进入对话的姿势只有一种:变成一条用户侧消息。对模型来说,它看到的永远只是一段对话,根本不知道也不需要知道这条消息背后是哪个机制产生的。

    我把这叫统一货币:所有事件先兑换成 messages,再进循环。没有"cron 专用通道",没有"后台任务专用格式"。谁想给模型递话,谁就学会说 messages 这一种语言。

    ③ 是防漂移的细节。Agent 干活一上头,三四个工具轮过去,早把最初的任务清单忘干净了,越干越歪。这里连续三轮没碰 todo_write,就插一条提醒。成本几乎为零,效果是把跑偏的 Agent 拽回来。

    ④ 是上下文的账房先生。调模型前先过四级压缩管线:

    tool_result_budget → snip_compact → micro_compact → compact_history

    先给大块工具输出套预算,再裁中段,超限了才动摘要——能裁就不删,能删才总结,每一步都比下一步便宜。而且裁剪手下有分寸:micro_compact 只在真超限时才动手,动手也只替换"较早且已经读过"的结果,最近 3 条消息永远保持完整,逼近阈值的 80% 就停手——宁可这轮少省一点,也不让模型突然失忆,忘了自己刚干了什么。

    同一时刻,记忆、技能目录、已连接的 MCP server 也组装进 system prompt,工具池现场重新拼一次——上一轮刚连上的 MCP server,这一轮的工具就位。

    ⑤ 外面那层错误恢复上一批就位了:429 退避重试、529 切备用模型、max_tokens 先升档再要续写、prompt 太长触发应急压缩。集成版没加新东西,只是确认它们在总装车上还灵。

    ⑥ 是三岔路口。每个 tool_use block 走到这,面临三种命运:被 PreToolUse 拦下(返回拒绝理由,也算一种 tool_result);标记了后台执行的 bash(拿占位符走人,真结果以后以通知形式回来);正常前台执行。三条路殊途同归——都变成 tool_result,都回 messages。

    你看,又是统一货币。事件进来是货币,结果出去还是货币。整个系统里只有一种数据在流动。

    一轮的完整流水线


    PART 03:权限不是一层 if,是一个挂点

    总装车上最容易被低估的,是权限的位置。

    直觉写法是把权限写成工具执行旁边的一层 if:if 危险: 拦下。单文件示例这么写没问题,集成版这么写会漏——你自己写的工具拦住了,那队友调用的工具呢?一次性 subagent 调用的呢?MCP 插进来的外部工具呢?

    而且集成版的工具池本身就不固定。每轮调模型前,assemble_tool_pool() 现场把两路工具合进一个池子:

    def assemble_tool_pool():
    tools = list(BUILTIN_TOOLS) # 26 个内置工具
    handlers = dict(BUILTIN_HANDLERS)
    for server_name, client in mcp_clients.items():
    for tool_def in client.tools:
    prefixed = f"mcp__{server_name}__{tool_def['name']}"
    if prefixed in origins: # 规范化后撞名,当场炸
    raise ValueError(
    f"MCP tool name collision: {prefixed!r}")
    tools.append(to_mcp_schema(tool_def))
    handlers[prefixed] = client.call_tool
    return tools, handlers

    内置 26 个打底,MCP server 连几个就动态插几个,connect_mcp("docs") 之后下一轮池子里就多出 mcp__docs__search。注意那个撞名检查:两个 server 的工具规范化之后名字撞了,当场抛异常,绝不悄悄合并——两个同名工具悄悄变成一个,调用路由到错误的 server,这种 bug 出了根本查不到。

    工具池合流与权限闸门

    池子是动态的,权限更不能绑死在某个工具的实现里。集成版的答案:权限不写在执行处,写成 PreToolUse Hook。看简化后的代码:

    def permission_hook(block):
    if block.name == "bash":
    command = block.input.get("command", "")
    for pattern in DENY_LIST: # rm -rf /、sudo、mkfs 这些
    if pattern in command:
    return f"Permission denied: '{pattern}' is on the deny list"
    if threading.current_thread() is not threading.main_thread():
    return ("Permission denied: interactive shell approval is "
    "unavailable during an asynchronous turn")
    if CONSOLE.ask(" Allow? [y/N] ") not in ("y", "yes"):
    return "Permission denied by user"

    if block.name in ("read_file", "write_file", "edit_file"):
    path = block.input.get("path", "")
    if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):
    return "Permission denied: path is outside the workspace"

    if block.name.startswith("mcp__") and \\
    mcp_tool_policies.get(block.name, "confirm") != "allow":
    if CONSOLE.ask(" Allow? [y/N] ") not in ("y", "yes"):
    return "Permission denied by user"

    return None # None = 放行

    这么挂之后,主循环里只剩一句 trigger_hooks("PreToolUse", block)。Lead 的工具、subagent 的工具、队友线程的工具,全部从这道门过——因为门挂在主干道上,没有旁路可绕。

    而且 Hook 是可以排队的:permission 挂一个,日志再挂一个,审计再挂一个,互不认识,各司其职。权限从"工具执行的一部分"升级成了"主干道上的独立检查站"。

    这段代码里还藏着两处容易被忽略的工程品味。

    第一处,MCP 工具的默认值是 confirm。宿主自己维护一份精确的已知只读工具名单(比如 mcp__docs__search),名单之外的 MCP 工具一律问用户。为什么不让 server 的 description 说了算?因为 description 是 server 自己写的——自我介绍不能当授权书。一个标着"只读查询"的工具,谁知道它背后干了什么。

    第二处,那句线程检查。threading.current_thread() is not threading.main_thread()——非主线程直接拒绝需要交互确认的操作。为什么?因为异步轮次(下面马上讲)可能发生在用户正在终端里敲字的时候,这时候弹一个"Allow? [y/N]"出来,会跟用户正在输入的内容抢终端,两边都是灾难。所以宁可拒绝,不抢输入。

    对危险动作,宁可错过,不可错放;对用户终端,宁可拒绝,不可打扰。


    PART 04:睡着的 Agent,谁来叫醒

    前面留了个坑:主循环跑完一轮就 return 了,Agent 就此睡着。那第二天早上九点的定时任务谁来执行?后台装依赖装完了谁来接着干活?

    答案是集成版真正的第二个循环——事件循环:

    def async_event_loop(history, context, session_state):
    while True:
    time.sleep(1)
    with agent_lock: # 和用户输入抢同一把锁
    fired = list(cron_queue) # 事件源①:到点的定时任务
    inbox = consume_lead_inbox() # 事件源②:队友消息/plan/关机
    if not fired and not inbox \\
    and not has_pending_background():
    continue # 事件源③:后台任务。全空,接着睡
    …
    agent_loop(history, context, active_request)

    这个后台线程每秒醒一次,检查三个事件源:cron 队列、Lead 收件箱、已完成的后台任务。三个全空,接着睡;任何一个有货,就叫醒一轮 agent_loop。

    注意那把 agent_lock。用户在前台敲一个问题会触发一轮循环,事件循环也可能同时想触发一轮——两轮对话操作的是同一份 history,并发跑必然写花。所以两边抢同一把锁:用户在用,事件循环排队等;事件循环在跑,用户输入等它跑完。两个循环,一份历史,一把锁。

    异步事件唤醒机制

    我拿两个实验跑过它,建议你也试试。

    实验一:3 分钟后提醒我开会,然后什么都别干,去泡杯茶。到点你会看到一条 [cron auto] 日志,Agent 自己醒来给自己安排了提醒——全程没人碰键盘。

    实验二:在后台安装依赖,同时继续阅读 README.md。bash 被标记成后台执行,主循环立刻拿到占位结果继续读文件;依赖装完,完成通知以事件形式唤醒下一轮。两件事在时间上重叠,在对话里串行——模型看到的永远是一段有条不紊的历史。

    最后是 cron 的交付语义,这里有个容易漏想的细节:到点的任务是先攒在 unacknowledged_cron_jobs 里,等包含这条 prompt 的模型调用成功,才确认消费;失败就塞回队列,重启后也会重新入队。

    翻译成人话:定时任务的通知是"至少送达一次"。宁可重复提醒你一次开会,绝不悄悄吞掉一次提醒。对一个没人值守的调度系统,吞任务是最恶劣的故障——重复只是烦,丢失是事故。


    结尾:从裸循环,到一台完整的 Harness

    回顾这一篇的三块核心:循环不动(结构一行不改,机制自己找挂点,主干道不认识任何一个机制)、统一货币(cron、后台、队友、工具结果,统统兑换成 messages 再进对话)、事件驱动(主循环睡了,事件循环每秒查三个源,拿同一把锁唤醒它)。

    再退远看一眼这辆车:26 个工具、长期记忆、自动压缩、任务图、队友线程、定时调度、可插拔的外部服务。它的第一个形态,是几十行代码、5 个工具、跑两轮就把上下文塞爆的裸循环。

    十四篇,每篇只加一个机制;这一篇,把它们装回同一台车——循环本身,还是第一天那三步。

    循环只是心跳,挂点才是器官——Agent 的强大,从来不在于跳了多少轮,而在于每一轮前后,长出了什么。

    互动时间:如果让你往这个循环上再挂一个机制,你会挂什么?(我先来:一个"预算 Hook",每次调模型前算一遍这轮的 token 成本,超线就拦。)评论区说说你的答案。


    下一篇预告:「从0学习 Claude Code」第十六篇——Workflow Runtime。车装好了,下一站解决新问题:有些活儿的步骤是固定的,凭什么每次都让模型现场发挥?把编排路径写进代码、记录运行进度、中断了还能接着跑——工作流运行时,给这台 Harness 装上"自动挡"。

    — END —

    苦猿 · 帮普通人把 AI 学进简历

    赞(0)
    未经允许不得转载:171主机测评 » Day65|从0学习 Claude Code(十五):Harness 集成,14 章的零件装回同一台车
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址