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

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

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

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

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

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

  1. cron 到点的提醒,怎么送进正在跑的对话?
  2. 后台任务完成了,谁来告诉 Agent?
  3. 队友发来的 plan,排在用户问题前面还是后面?
  4. 上下文压缩和记忆注入,谁先谁后?
  5. 用户正在敲键盘,定时任务到点了,两轮对话撞车怎么办?

每一个单拎出来都不难,难的是它们共享同一段对话、同一个工具池、同一份上下文预算------动任何一个,其他全部跟着晃。

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

  1. 一张全景图:十几种机制,各自挂在循环的哪个位置
  2. 逐段读懂主循环的能力:一轮从进门到出门,到底发生了什么
  3. 事件驱动机制: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 学进简历

相关推荐
Leo.yuan2 小时前
Agent化分析加速成形:FineBI AI原生技术架构落地,推动AI+BI分析范式跃迁
大数据·人工智能
YH行业报告分析2 小时前
2026商用鸡蛋分级机深度分析:AI视觉分拣如何驱动蛋品加工智能化升级?
大数据·科技·智能
DolphinDB智臾科技2 小时前
插件上新 | 让 Excel 数据更顺畅地接入 DolphinDB
大数据·excel
云安全助手3 小时前
自建接入VS聚合平台:企业 AI 调用的选型思路与迁移成本拆解
java·大数据·数据库·人工智能·ai大模型
Gu_WenYun3 小时前
从全面普涨到双轨分化,如何用基金布局存储芯片?
大数据·人工智能·业界资讯
陈工大模型3 小时前
GEO监测工具技术选型:2026年9月AI可见性十强评测
大数据·人工智能·科技
CallFay云起未来3 小时前
AI客服上线后多久才能回本?从TCO到ROI的完整测算方法
java·大数据·人工智能·架构·文心一言
Elastic 中国社区官方博客3 小时前
使用 Lucene 搜索你的 Bean —— Elasticsearch
大数据·开发语言·人工智能·elasticsearch·搜索引擎·全文检索·lucene
闲蛋小超人笑嘻嘻4 小时前
Git Worktree 详解
大数据·elasticsearch·搜索引擎