Claude Code 为什么不只会回答,还能继续做事?借它看懂 Agent 背后的 Harness

0 阅读15分钟

用 Claude Code 或者 Codex 的时候,你只告诉它「实现这个需求」或者「帮我修这个 bug」。

后面的事,它会自己去翻项目、找关联文件、规划改动、跑测试、看结果。

你没有一步步告诉它先读哪个文件、再跑哪个命令,它也能自己往下推进。

它是怎么把这些事一路接下去的?LLM 不是只会一问一答吗?

最近很火的 Harness Engineering,讲的就是这层运行时骨架

网页 Chat 和 Claude Code、Codex 这类 Agent 工具,外面都有 Harness。

只是一般的 AI 对话功能,这层很薄,最薄的时候只是整理 messages、调一次模型、把回答流式刷回来,一圈就停。

Agent 工具这层会厚很多。

它会让模型继续读文件、调工具、拿结果,再回去判断下一步。

Harness 跑得越深,模型能自己接下去的事情就越多。

先划重点

你用的网页 Chat、Claude Code、Codex,外面都套着一层 Harness

差别只在它搭到多深——Chat 一圈就停,Agent 会一直往下跑。

骨架就是一个循环,每圈五件事:整理输入 → 调 LLM → 判断要不要调工具 → 跑工具 → 拼回下一圈

从 demo 到能交付的 AI 应用,中间是Harness 的六块工程能力

主循环、工具注册、上下文管理、权限安全、多 Agent 编排、持久化与记忆。

cover-rec.png


Model 只负责一次调用,Agent 靠 Harness 往下跑

可以先把 Model 想成一个「大脑」。

它理解能力很强,推理也快,也能判断下一步想做什么。

比如它可以在回答里说:「我想读这个文件」「我想调这个工具」。

但 Model 只会输出这个意图,它自己不会真的去读文件、执行命令、拿结果,也不会把结果塞回下一轮继续跑。

这就像一个很聪明的人刚到公司,他知道接下来应该查代码、跑测试、看日志。

但电脑还没发,账号没开,工具没配,系统权限没给,流程和交接文档也没有。

他想做这件事,也没有地方下手。

Harness 补的就是这套工作环境和调度系统。

要看懂 Agent 是怎么工作的,可以先把这套东西拆成三块:

Model、Harness、Tool。

Model 负责决定下一步想做什么。它会输出一段 tool_use,意思是「我想调哪个工具,参数是什么」。

Harness 负责解析这段 tool_use。它检查这一步能不能做,交给工具执行,拿到结果,再把结果放回下一轮上下文。

Tool 才是具体能力,比如 ReadBashGrep。读文件、跑命令、搜索代码,都是 Tool 真正在做。

所以我会把 Agent = Model + Harness 先理解成这件事:

光有大脑不够,还得有一套能让它动起来的工作环境。

agent-model-harness-tool.png

如果只是一问一答,骨架其实很短:

// 普通调用:回一条结果,这一轮就结束
const response = await llm.call(messages);
return response;

到了 Agent,Harness 会解析 tool_use,执行对应工具,再把结果送回下一轮:

while (true) {
	// 先调一次 LLM,看它这一轮想做什么
	const response = await llm.call(messages);

	/*
	 * response 里可能同时有 text 和 tool_use。
	 * text 是给用户看的文字,tool_use 是模型说「我要调工具」。
	 * 这里直接从 content 里找 tool_use。
	 */
	const toolUses = response.content.filter(
		block => block.type === 'tool_use'
	);

	// 没有 tool_use,说明这一轮只是回答,任务到这里结束
	if (toolUses.length === 0) {
		return response;
	}

	// 有 tool_use,就执行工具,把 tool_result 写回 messages,再进入下一圈
	const results = await executeTools(toolUses);
	messages.push(...results);
}

两段代码真正不一样的地方,就在 toolUses.length === 0 这个判断。

没有 tool_use,这轮就直接返回。

tool_use,Harness 先执行工具,把 tool_result 写回 messages,再让它进下一圈。

chat-vs-agent-loop.png


Agent 往下做事时,会反复走这五步

前面那段 while(true) 只是骨架。只要模型还需要调工具,Harness 就会按这个顺序继续往下走。

runtime-five-step-loop.png

Claude Code 的实现里,这五步就是主循环的骨架,我们逐步对照,看每一层是怎么做的。

上下文治理 — 把喂给 LLM 的 messages 整理干净

调 LLM 之前,Harness 会先整理这一轮要喂进去的 messages,避免上下文撑爆。

太长就想办法缩短,要么把前面的旧对话摘要掉,要么把某条工具结果截断

LLM 每次只能看一个有限的窗口,这一层要保证喂进去的东西塞得下。

那 Claude Code 里面是怎么处理的?

/**
 * 只取最近这段
 */
let messagesForQuery = [...getMessagesAfterCompactBoundary(messages)]

/**
 * 工具结果太长就截断
 */
messagesForQuery = await applyToolResultBudget(...)

/**
 * 合并重复的工具结果
 */
const microcompactResult = await deps.microcompact(...)

/**
 * 接近上限才触发 LLM 压缩
 */
const { compactionResult } = await deps.autocompact(...)

取最近这段消息,截断太长的工具结果,再把重复读取的内容合并掉。

能用规则处理的先用规则处理,真的快装不下了,再用 LLM 做压缩 ——

因为上下文压缩可是会烧掉不少 token的。

调 LLM — 让大脑说一次话

整理完 messages,Harness 会把 messages 和工具清单一起发给 LLM。

这一轮模型会返回两类东西:

给用户看的文字,以及可能出现的 tool_use

tool_use 其实就是模型的下一步意图:

它想调哪个工具,参数是什么

const response = await llm.call(messages, tools);

// response.content 里可能同时有 text 和 tool_use
response.content = [
	{ type: 'text', text: '我来帮你读文件' },
	{
		type: 'tool_use',
		name: 'Read',
		id: 'toolu_xxx',
		input: { file_path: 'src/api.ts' }
	}
];

工具意图是 LLM 自己在回答里带出来的,Harness 只管执行——谁决策、谁执行,分得很清楚。

判断 tool_use — 它想调工具,还是想直接答你

response.content 里有没有 tool_use,决定这一圈怎么走。

没有,模型直接回答了,循环退出。

有,进下一步执行工具。

用户给的是任务描述,模型自己判断这一轮要不要调工具。

你问一个能直接回答的问题,它不需要工具,这轮就结束;

但如果你的问题涉及最新的时效性数据,它可能就会先调搜索工具。

你让 Claude Code 修 bug,它会连续请求读文件、查日志、改代码、跑测试

任务越复杂,模型请求工具的次数越多,Harness 要跑的轮次也就越多。

执行工具 — 真的去跑那个工具,拿到结果

在执行工具这一步,Harness 要做三件事:

检查能不能执行、调用真正的 Tool、收集工具结果

比如 Read('src/api.ts'),Harness 需要先确认这个路径能不能读、有没有权限,检查通过后才能执行这个读取文件的工具。

for (const call of toolUses) {
	/**
	 * 询问用户是否允许执行这个工具
	 */
	await checkPermission(call);
	const result = await runTool(call);
	toolResults.push({
		type: 'tool_result',

		/**
		 * 和 tool_use 的 id 配对
		 */
		tool_use_id: call.id,
		content: result
	});
}

这一轮所有工具都跑完以后,Harness 才会进入下一步,开始组装下一圈 messages

组装下一圈 — 把结果拼回 messages,回第一层

回到下一轮之前,Harness 要把三份东西合并好:

之前的 messages 历史、这一轮 LLM 带着 tool_use 的回答、这一轮所有工具结果。

拼好之后 continue,回到第一步的上下文治理,再开始下一圈循环。

state = {
	messages: [...messagesForQuery, ...assistantMessages, ...toolResults],
	turnCount: state.turnCount + 1
};

LLM 下一圈看到的是:

我说过要调 Read 工具,Read 返回了这些内容,现在轮到我决定下一步。

messages 数组就是 LLM 的记忆,每圈拼一次,循环才能一直接下去。

messages-relay-baton.png

到这里,再回头看这五步的顺序,就很清楚了:

上下文治理在前:先把 messages 整干净,再喂给模型。

工具执行在后:先等模型返回 tool_use,Harness 才知道该不该跑工具、跑哪个、带什么参数。

简单问题一轮结束,复杂任务多轮推进

同样是这个循环:

任务越简单,停得越早。

你问它一个概念问题,比如「这个错误大概是什么意思」,模型直接回文字,没有 tool_use,一圈就结束。

你让它看一个具体文件,比如「帮我看看这个接口为什么返回空数组」,它可能先 Read 文件,再结合结果回答,通常多跑一两圈。

你让 Claude Code 修一个真实 bug,就不一样了。

它会先读报错,再 Grep 相关代码,打开几个文件,改一版,跑测试;测试失败,再读失败日志,继续改。

这时候循环跑的就不是一两圈了。

骨架其实还是那五步,差别就在于:

任务越复杂,模型越需要工具结果,Harness 就要陪它跑更多轮。


Harness 的六块能力,都是循环跑深以后冒出来的问题

前面那五步,讲的是 Agent 一轮一轮怎么跑

但 Harness 不只是一段 while(true)

那段循环只是运行路径。要让它从 demo 变成能交付的 AI 应用,问题很快就会冒出来:

模型能调哪些工具?

每个工具的参数长什么样?

上下文快爆了怎么办?

Bash 这种执行 shell 命令的高风险工具,什么时候必须让用户确认?

一个任务太大,能不能拆给子 Agent 并发跑?

跑到一半挂了,重启后能不能接着做?

这些问题合在一起,才是 Harness 真正吃重的地方。

拆开看,就是六块能力:

主循环:让模型不止回答一次,而是拿结果继续判断。

工具注册:告诉模型能用哪些工具、每个工具怎么传参。

上下文管理:控制 messages 的长度和质量,别让它撑爆。

权限安全:在真正执行工具前,拦住危险操作。

多 Agent 编排:任务太大时,把一部分工作交给子 Agent。

持久化与记忆:让状态和偏好跨轮、跨会话保留下来。

harness-six-capabilities-map.png

主循环 — 让模型不只回答一次

一次模型调用结束后,还能继续读结果、再判断下一步,靠的是外面那个 while(true) 主循环

没有这层循环,一次模型调用结束,这轮任务也就结束了。

加上这层循环,事情就能继续往下走:

看一次结果,判断一次下一步,再决定要不要继续调工具。

放到 Claude Code 的主流程里看,跑循环的核心函数叫 query

它里面大概有三个出口:

/**
 * 正常结束
 */
if (!needsFollowUp) return { reason: 'completed' };

/**
 * 轮数兜底
 */
if (nextTurnCount > maxTurns) return { reason: 'max_turns' };

/**
 * 用户中断
 */
if (abortSignal.aborted) return { reason: 'aborted_tools' };

这三个出口里,最常走的是第一条。

needsFollowUp 为 false,说明这一轮没有 tool_use

模型已经把话说完了,后面没有工具要跑,循环就停。

如果这一轮带了 tool_use,那就还没完:

Harness 会去跑工具,把结果放回 messages,再让模型判断下一步。

轮数兜底用户中断其实就是两道保险:

一个防止它在任务里无限绕下去,一个让用户随时能刹车。

loop-exit-guards.png

工具注册 — 让模型知道自己能用什么

模型不会凭空知道系统里有哪些工具。

工具注册解决的就是这件事:调用模型之前,先准备一份工具清单

在 Claude Code 里,ReadBashGrep 这些内置工具,是代码里先定义好的。

用户接入 MCP 以后,外部工具也会被包装成同一类工具对象

实际发请求前,这份清单会从两路拼出来:产品内置工具,加上用户接入的 MCP 外部工具

拼好以后,它会跟 messages 一起放进 LLM 请求里。

模型看到这份清单,才知道这一轮能使用哪些工具。

而这份工具清单,至少要说清三件事:工具叫什么、什么时候该用、参数怎么填

const response = await llm.call(messages, [
  { name: 'Read', description: '读文件', inputSchema: {...} },
  { name: 'Bash', description: '执行 shell', inputSchema: {...} },
  { name: 'Grep', description: '搜索代码', inputSchema: {...} }
])

所以 tool_use 是模型看完工具清单以后,返回的一次工具请求

如果模型返回了清单外的工具名,主流程找不到对应工具,也就不会执行。

tool-registry-contract.png

上下文管理 — 别让 messages 撑爆

每次调用模型前,messages 都要先过一遍。

上下文窗口再大,也不是无限的。

Agent 连续读文件、跑命令、拿日志,messages 会涨得很快。

一旦上下文又长又乱,模型就容易降智:

漏看上下文、重复绕路、把已经确认过的事又翻出来怀疑一遍。

在实际实现中,一般不会一上来就让 LLM 总结整段上下文历史。

先做便宜的处理:该截断的截断,该合并的合并,真的快装不下了,再压缩。

拆开看,其实就是三类动作:

预算分配:system prompt、工具结果、历史消息,各自能占多少。

规则整理:截断太长的工具结果,合并重复读取的内容。

自动压缩:接近上限时,让 LLM 生成摘要,替换掉一部分老历史。

关于上下文压缩的思路,感兴趣的朋友可以看看上一期的文章:link。

权限安全 — 敏感操作要先确认

工具真正执行前,还要过一道权限判断。

只看工具名不够。

同样是执行 shell 命令,ls -la 只是看看目录,rm -rf / 就是高风险操作

同样是读文件,一个是当前工作区里的 src/api.ts,一个是系统路径里的 /etc/passwd权限判断也会不一样

所以权限判断要看两样东西:工具名 + 这次传进来的参数

tool-permission-check.png

放到代码里,可以先把它想成一个 canUseTool(tool, input)

tool 说明它想用哪个能力,input 说明这次具体要做什么。

async function canUseTool(tool, input) {
	if (tool.name === 'Bash' && isDangerous(input.command)) {
		// 敏感操作先停下来,让用户确认
		return await askUser();
	}

	// 安全操作直接放行
	return 'allow';
}

在 Claude Code 里,每次执行敏感工具前,都会先停下来问用户要不要继续。

用户没确认之前,这次工具调用就不会往下跑。

多 Agent 编排 — 把一部分工作交给子 Agent

任务一大,全放进一个 Agent 跑,上下文就会越来越乱。

主目标、读过的文件、跑过的命令、还没处理完的反馈,全混在一起。

OpenAI 在他们的 Harness Engineering 博客里讲过一个 Codex 的工作流,是这么解决的:

Codex 先写代码,开 PR,然后请几个 agent 分别评审,每个只看一件事:代码质量、测试覆盖、安全问题、规范检查

评审意见回来,Codex 继续改,改完再送审,一轮不过再来一轮,直到所有 reviewer 都点头

111 10.22.53.png

OpenAI 把这套流程叫 Ralph Wiggum Loop

Ralph Wiggum 是《辛普森一家》里那个一直说 I'm learnding! 的呆萌小孩,有点懵,有点笨拙,但很认真地在学。

ralph-wiggum-learnding.gif

拿他来命名,我觉得是一个很精准的调侃:

很多看起来很高级的多 Agent 编排,拆开以后,本质上其实就是一个老老实实不断迭代的循环。

别期待第一轮就对,先跑一轮,拿反馈,再改一轮。

持久化与记忆 — 任务能接上,记忆跨会话

持久化解决的是中断后能否续上。

崩溃了或者主动退出,历史都在,用 /resume 重新打开能从断的地方接着跑。

记忆解决的是跨会话失忆。

今天你让它记住「我项目用 pnpm 不用 npm」,明天新开一个会话,它还记得。

Claude Code 把记忆分两块管——

CLAUDE.md固定记忆,每圈都带着

~/.claude/memory/自动记忆,按相关性筛选后注入

persistence-vs-memory.png

// CLAUDE.md 内容进 system prompt,每次调用都带着
const systemPrompt = buildSystemPrompt({ claudeMd });

// 记忆预取放在循环外,只读一次,不随每圈重复触发
const pendingMemoryPrefetch = startRelevantMemoryPrefetch(userMessage);

while (true) {
	// 按相关性筛选后注入 messages,每次用户消息只注入一次
	messages = await injectMemoryOnce(pendingMemoryPrefetch, messages);

	const response = await llm.call(messages, tools);
	// ...
}

这条链路,就是 Harness 在做的事

Claude Code 真实的主循环实现有上千行,大部分在处理各种边界——

中断、超时、记忆预取、多 Agent 分流、MCP 工具接入。

但背后机制其实就是:

Model 负责推理,Harness 负责其他所有事:把上下文喂进去、决定调哪个工具、管权限、拼下一圈。

Claude Code、Codex 这类工具,用起来像是它在自己做决定。

拆开来,就是一个 while(true) 加一个 if(tool_use)

差别只在 Harness 搭到多深。


读完回顾

Q1:Claude Code 修一个 bug,可以自己翻文件、改代码、跑测试,不用你每步都发消息推它——为什么?

💡 提示:回到 Model + Harness 公式

Q2:你做的 AI 产品接了 PDF 问答,用户反馈「对话跑十几轮之后 AI 开始胡说八道」,你会先去查 Harness 六块能力里的哪一块?

💡 提示:六块能力各自负责什么,哪块和「对话越长、质量越差」最直接相关

Q3:Harness 六块能力里,哪一块去掉之后 Agent 跑起来最危险?为什么?

💡 提示:想想 Agent 能执行的操作,以及出错了能不能撤回


延伸阅读


如果这篇帮你理清了 Agent 运行时的骨架,下一篇我们往这条链路里填入第一张拼图,看看 Agent 的知识获取到底怎么做

感兴趣可以关注微信公众号前端Fusion,不错过后续更新。

分享底图_压缩.png