AI Agent 工程化系列第五篇。本文以 Cherry Studio 的 Tool Registry、Claude Code settings、审批、会话宿主与可观测性为例,说明 Harness Engineering 如何把"模型能做什么"变成"产品允许它在什么边界内做什么"。文中的伪代码用于解释设计逻辑,不是可直接复制的生产代码。
写在前面:模型说"我能做",不等于系统真该让它做
做 AI 桌面端工具时,给模型接工具并不难:注册一个 schema,写一个执行函数,模型就能发起调用。真正麻烦的是后半句------它能访问哪个目录?能不能写文件?要不要用户确认?取消后副作用怎么收尾?出了问题又该查谁?
这些问题不会因为 Prompt 写了"请谨慎操作"就自动消失。团队在研究 Cherry Studio 时,最值得借鉴的一点正是:模型负责提出动作,运行时负责决定动作是否能在正确边界内发生。
这层承上启下的控制系统,就是 Harness。
1. Harness 是什么
Prompt、Context 和 Loop 解决了模型如何理解任务、获得事实、推进多步骤执行的问题。Harness 解决的是另一个问题:
当模型决定执行一个动作时,系统如何确保该动作在正确的工具、权限、目录、会话和审计边界内发生?
Harness 不是单个类,而是运行时的控制面:
它通常包括:
- 工具注册、发现、延迟暴露和禁用。
- MCP server 的连接、缓存、OAuth 与会话快照。
- 工作区、知识库、文件系统和频道的访问范围。
- 审批、自动批准与拒绝语义。
- 会话生命周期、流订阅、取消、恢复和持久化。
- trace、usage、错误和敏感数据的审计边界。
Harness 是将 Agent 从 Demo 变成产品的关键层。没有它,模型输出的工具调用只是未经约束的建议。
2. 先分清:模型看得见,不代表系统会放行
工具有至少四个不同状态:
text
registered 工具在系统中存在
visible 工具描述与 schema 进入模型上下文
callable 模型本轮可以发起调用
executable 运行时检查、审批通过后真正执行
它们不能合并为一个布尔值。否则"工具已经注册"很容易被误解为"这次调用一定能执行"。
例如一个删除文件的工具:
| 状态 | 结果 |
|---|---|
| registered | 工具实现已随应用安装。 |
| visible | 当前 Agent 能知道该工具存在。 |
| callable | 工具没有被用户禁用,且当前工作区满足前置条件。 |
| executable | 当前调用获得用户批准,输入也通过路径和权限校验。 |
Prompt 可以解释工具使用策略,但只能影响模型是否尝试调用;Harness 决定工具是否真正暴露和执行。
3. 回到 Cherry Studio:两条运行时,两套工具适配
Cherry Studio 有两条不同的工具适配路径:
两条路径共享产品能力,但不共享同一个工具注册实现。原因是 AI SDK 和 Claude Agent SDK 的工具协议、会话模型与权限回调不同。
这要求产品团队把领域能力 与SDK 适配分开:
text
领域能力:KnowledgeService、FileManager、WebSearchService
工具契约:schema、描述、权限级别、输出映射
SDK 适配:AI SDK Tool / Claude MCP descriptor
不要把业务逻辑写进某个特定模型 SDK 的 tool callback;否则新增 Driver 时会复制权限和审计逻辑。
4. 关键算法一:每一轮只给模型必要的工具
普通 Chat 使用 ToolRegistry。每个注册项包含名称、namespace、描述、defer 策略、工具实现与 applies(scope) 谓词。
ts
type ToolEntry = {
name: string
namespace: string
description: string
defer: "never" | "always" | "auto"
tool: Tool
applies?: (scope) => boolean
}
工具选择应按请求动态计算,而不是在应用启动时固定:
ts
function resolveActiveTools(registry, requestScope) {
active = []
for (entry of registry.entries()) {
try {
if (entry.applies && !entry.applies(requestScope)) {
continue
}
active.push(entry)
} catch (error) {
logWarning("Tool applicability failed", entry.name, error)
// fail closed:不能确认适用时,不向模型暴露
}
}
return active
}
requestScope 可以包含:
- Assistant 是否启用了 Web Search。
- 当前请求是否带有文件附件。
- 当前用户是否有知识库。
- Agent 的有效知识库范围。
- 模型是否具备工具调用能力。
- Provider 是否支持某类原生能力。
这让工具面成为当前请求的函数:
text
ToolSurface = f(assistant, model, provider, session, permissions, request)
而不是简单的全局数组。
5. 关键算法二:依赖传播到不动点
工具之间可能存在依赖。例如 ExitWorktree 依赖 EnterWorktree;若前者可见而后者被禁用,模型会看到一个不可完成的动作。
Claude Code 路径的 resolveDisallowedTools 使用不动点算法传播禁用状态:
ts
function resolveDisallowedTools(toolDefinitions, userDisabled, runtimeContext) {
blocked = new Set()
// 第一轮:直接禁用
for (tool of toolDefinitions) {
if (tool.exposure === "disabled") {
blocked.add(tool.name)
continue
}
if (tool.exposure === "user" && userDisabled.has(tool.name)) {
blocked.add(tool.name)
continue
}
predicate = tool.enablePredicate
if (predicate && !predicate(runtimeContext)) {
blocked.add(tool.name)
}
}
// 后续轮:禁用依赖于已禁用工具的工具
changed = true
while (changed) {
changed = false
for (tool of toolDefinitions) {
if (blocked.has(tool.name)) continue
if (tool.dependsOn.some(dep => blocked.has(dep))) {
blocked.add(tool.name)
changed = true
}
}
}
return [...blocked]
}
为什么需要循环
设依赖链为:
text
ToolC → ToolB → ToolA
当 ToolA 被禁用时:
- 第一轮发现
ToolA。 - 第二轮禁用
ToolB。 - 第三轮禁用
ToolC。
单次遍历是否足够取决于声明顺序,不动点算法则与工具注册顺序无关。
对于工具数量为 V、依赖边为 E 的小型注册表,该实现最坏约为 O(V × (V + E))。工具图通常很小,可读性与正确性优先于复杂的拓扑优化;若未来工具图大规模增长,再改为反向依赖图上的 BFS。
6. 关键算法三:工具延迟暴露与 token 预算
大型 MCP 生态会带来数百个工具 schema。将全部工具直接放入模型上下文会造成:
- Prompt token 膨胀。
- 模型在相似工具之间选择困难。
- 首 token 延迟和调用成本上升。
Cherry Studio 使用 deferred exposition:将一部分工具从初始工具集移除,改为提供 tool_search、tool_inspect 和 tool_invoke。
text
初始上下文:
少量常用工具
+ tool_search / tool_inspect / tool_invoke
+ 可用 namespace 摘要
模型需要罕见工具时:
tool_search → tool_inspect → tool_invoke
是否 defer 不能只比较工具数量。meta tools 自身有固定 Prompt 成本,因此必须判断净收益:
ts
function shouldDefer(entries, contextWindow) {
autoCandidates = entries.filter(entry => entry.defer === "auto")
if (autoCandidates.length < MIN_AUTO_DEFER_COUNT) {
return []
}
estimatedSavedTokens = estimateSchemasTokens(autoCandidates)
metaToolsCost = META_TOOLS_OVERHEAD_TOKENS
if (estimatedSavedTokens <= metaToolsCost) {
return []
}
return chooseDeferredEntries(autoCandidates, contextWindow)
}
这是一个典型的成本模型:
text
netSaving = inlineToolSchemaTokens - metaToolStaticTokens - expectedDiscoveryTokens
若 netSaving <= 0,延迟暴露会增加而不是减少成本。
6.1 审批工具永不 defer
审批工具必须保持 inline:
ts
function classifyDeferPolicy(tool) {
if (tool.needsApproval) {
return "never"
}
return tool.defer
}
如果审批工具被 defer,模型可以通过 tool_invoke 间接调用;原 SDK 的审批 gate 可能无法触发。Cherry Studio 同时在 meta tool 的执行路径再次拒绝 approval-gated 工具,形成双重防线。
结论是:
优化模型上下文不能改变权限语义。
7. 关键算法四:Agent Session 的运行时装配
Harness 的重要职责是在连接 Agent Driver 之前构建一份一致的、可冻结的运行时配置。
Agent Session 的 settings 构建可简化为:
ts
async function buildSessionSettings(session, provider, options) {
assertSessionHasAgentAndWorkspace(session)
await prepareWorkspaceDirectory(session.workspace)
// 并行执行互不依赖的初始化
[agentDataPath, env, workspacePlugins] = await Promise.all([
ensureAgentDataDirectory(session.agentId),
buildEnvironment(provider, session.agent),
discoverWorkspacePlugins(session.workspace.path)
])
warmResult = await warmMcpToolCaches(session.agent)
permissions = await buildToolPermissions(
session,
session.agent,
agentDataPath
)
knowledgeScope = resolveKnowledgeBaseScope(
session.agent.knowledgeBaseIds,
options.selectedKnowledgeBaseIds
)
prompt = await buildSystemPrompt({
session,
agent: session.agent,
cwd: session.workspace.path,
agentDataPath,
knowledgeScope,
disallowedTools: permissions.disallowedTools
})
mcpServers = buildMcpServers({
session,
agent: session.agent,
knowledgeScope
})
return {
cwd: session.workspace.path,
additionalDirectories: [agentDataPath],
env,
plugins: workspacePlugins,
systemPrompt: prompt,
mcpServers,
canUseTool: permissions.canUseTool,
disallowedTools: permissions.disallowedTools
}
}
这份 settings 是 Agent Session 的 capability snapshot。其价值在于:
- Prompt 中的工具指引与实际工具面一致。
- 知识库 scope 同时用于 Prompt、MCP bridge 和权限判断。
- Agent data directory 与工作区分开,避免长期记忆污染用户项目。
- 运行环境、插件、工具策略和审批回调在连接前就明确。
7.1 慢 MCP 的 bounded warm 与最终一致性
MCP 服务可能慢或不可用。若每次会话启动都同步 listTools,一个故障服务就会阻塞聊天。
因此热路径读取 last-known-good cache;首次冷缓存时触发后台刷新:
ts
async function listToolsWithoutBlocking(serverId) {
cached = cache.get(`mcp.tools.${serverId}`)
if (cached is undefined) {
void refreshToolsInBackground(serverId)
}
return cached ?? []
}
这带来最终一致性:本次 session 在缓存尚未预热时可能看不到某些工具,后续 session 才会看到。对于 Claude Agent SDK,工具列表在会话建立时快照化,不能在同一会话中任意扩容。
settingsBuilder 对 bounded warm 超时的场景会在后台预热完成后,刷新工具元数据与 policy snapshot,避免"模型可见工具"和"审批 UI 元数据"长期不一致。
8. 关键算法五:审批状态的单写者模型
工具审批涉及 Renderer、Main、数据库和可能仍在运行的 Agent Driver。若任何一方都能直接改审批状态,竞态会迅速出现。
Cherry Studio 采用 Main 单写者:
text
Renderer:展示 approval card,提交用户决定
Main:验证、写入权威状态、恢复对应运行时
概念状态机:
伪代码:
ts
async function respondToApproval(request) {
live = approvalRegistry.get(request.approvalId)
if (live.belongsToClaudeAgentSession) {
// 解除 canUseTool 上等待的 promise;无需读写普通 Chat 消息行
live.resolve(request.decision)
return
}
anchor = messageService.getById(request.anchorId)
part = findApprovalPart(anchor.parts, request.approvalId)
if (!part) {
// Renderer 可能先于持久化看到 overlay;不能盲写覆盖数据库
return
}
updatedParts = applyApprovalDecision(anchor.parts, request.decision)
messageService.update(anchor.id, { parts: updatedParts })
if (allApprovalsResolved(updatedParts)) {
dispatchContinueConversation(anchor)
}
}
关键不变量:
- Renderer 不直接写数据库。
- 只有 Main 能从
awaiting-approval恢复流。 - 对 DB anchor 的写入必须确认目标 approval part 仍存在。
- Claude live session 和 MCP continuation 使用不同恢复机制,但共享同一个产品状态模型。
这避免了 overlay 先显示、数据库后落盘时的覆盖竞态,也保证多窗口看到同一份审批状态。
9. 关键算法六:工作区与 Agent 数据的双目录边界
Agent Session 同时需要:
- 用户工作区:代码、文档和任务文件所在目录。
- Agent 数据目录:SOUL、USER、FACT、JOURNAL 等跨会话记忆。
它们不能混为同一目录。
text
cwd = session.workspace.path
additionalDirectories = [agentDataPath]
概念校验:
ts
function validateFileOperation(targetPath, workspacePath, agentDataPath) {
if (isInside(targetPath, workspacePath)) {
return allow()
}
if (isInside(targetPath, agentDataPath)) {
return allow()
}
return requireUserApproval("Path is outside the current workspace")
}
这一边界使 Agent 能维护自己的长期身份和记忆,同时不能因为拥有 Agent 数据目录就任意读取用户磁盘。
目录校验不应只使用字符串前缀比较,应使用规范化、真实路径解析和 symlink 防护。否则:
text
/workspace-safe/../secret
/workspace-safe-link → /secret
可能绕过简单的 startsWith("/workspace-safe") 检查。
10. 关键算法七:Trace、Usage 与敏感数据边界
Harness 还负责回答"这次 Agent 到底做了什么"。
Cherry Studio 为 AI SDK 调用建立 span tree:
text
chat.turn
├─ ai.streamText
├─ ai.streamText.step
├─ ai.toolCall
└─ usage / model / topic attributes
概念实现:
ts
async function runObservedTurn(request) {
root = trace.startSpan("chat.turn", {
topicId: request.topicId,
modelName: request.modelName
})
try {
stream = await aiService.streamText(request, root.context)
result = await pipeAndPersist(stream)
root.setStatus("ok")
return result
} catch (error) {
root.recordException(error)
root.setStatus("error")
throw error
} finally {
root.end()
traceStorage.flush(request.topicId)
}
}
Trace 并非默认无害。开发模式下 Claude Code 的 verbose telemetry 可包含用户 Prompt、工具内容甚至原始 API body,并被写入本地 JSONL。因此必须明确:
- trace 默认仅在开发者模式启用。
- trace 文件视为敏感数据,不能默认上传或共享。
- "本地保存"不等于"不需要数据安全设计"。
11. 这些 Harness 坑,最好提前绕开
反模式:只在 Prompt 中声明权限
模型可能忽略、误解或被注入内容影响。必须在工具选择和执行时再次做强制检查。
反模式:把所有 MCP 工具直接放入模型上下文
这会增加 token、延迟和选择错误。应按使用频率与净 token 收益做延迟暴露,但审批工具必须例外。
反模式:工具禁用只做一次遍历
依赖链会留下半可用工具。应传播到不动点或使用反向依赖图。
反模式:Renderer 直接更新审批状态
多窗口、overlay 与持久化会产生竞态。审批状态必须由 Main 统一写入和恢复。
反模式:让慢 MCP 阻塞 Agent 启动
工具发现应使用 last-known-good cache 和后台刷新;产品应接受并显示最终一致性,而不是让聊天无法开始。
反模式:为了排障默认记录全部 Prompt 与工具 body
可观测性会变成敏感数据存储。应开发者模式门控、明确保存位置和导出策略。
12. 小结
Harness Engineering 的成熟标志不是"接入了很多工具",而是系统能够清楚回答:
- 当前模型为什么能看到这个工具,而看不到另一个?
- 该工具真正执行前还会经过哪些权限、目录和审批检查?
- MCP 失效、缓存未预热或工具列表变化时,系统如何降级?
- 用户能否在不破坏执行状态的前提下批准、拒绝、取消或恢复任务?
- 是否能审计一次执行的模型、工具、审批、成本与敏感数据边界?
一句话总结:
Harness 不负责让模型更聪明;它负责让模型能力在产品中变得可授权、可观察、可恢复且可承担责任。