Harness Engineering:让 Agent 在受控边界内运行

AI Agent 工程化系列第五篇。本文以 Cherry Studio 的 Tool Registry、Claude Code settings、审批、会话宿主与可观测性为例,说明 Harness Engineering 如何把"模型能做什么"变成"产品允许它在什么边界内做什么"。文中的伪代码用于解释设计逻辑,不是可直接复制的生产代码。

写在前面:模型说"我能做",不等于系统真该让它做

做 AI 桌面端工具时,给模型接工具并不难:注册一个 schema,写一个执行函数,模型就能发起调用。真正麻烦的是后半句------它能访问哪个目录?能不能写文件?要不要用户确认?取消后副作用怎么收尾?出了问题又该查谁?

这些问题不会因为 Prompt 写了"请谨慎操作"就自动消失。团队在研究 Cherry Studio 时,最值得借鉴的一点正是:模型负责提出动作,运行时负责决定动作是否能在正确边界内发生。

这层承上启下的控制系统,就是 Harness。

1. Harness 是什么

Prompt、Context 和 Loop 解决了模型如何理解任务、获得事实、推进多步骤执行的问题。Harness 解决的是另一个问题:

当模型决定执行一个动作时,系统如何确保该动作在正确的工具、权限、目录、会话和审计边界内发生?

Harness 不是单个类,而是运行时的控制面:

flowchart LR users["用户 / 外部渠道"] --> sessionGuard["会话与工作区校验"] sessionGuard --> toolPolicy["工具暴露与权限策略"] toolPolicy --> agentDriver["模型 / Agent Driver"] agentDriver --> execution["审批、工具执行、流式事件"] execution --> runtimeState["持久化、观测、恢复与 UI"]

它通常包括:

  • 工具注册、发现、延迟暴露和禁用。
  • 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 有两条不同的工具适配路径:

flowchart LR subgraph chatPath["普通 Chat"] chatRegistry["AI SDK ToolRegistry"] --> chatTools["内置工具 / MCP 工具 / meta tools"] chatTools --> chatAgent["AI SDK Agent"] end subgraph sessionPath["Agent Session(Claude Code)"] sessionDescriptors["Claude Code tool descriptors + MCP servers"] --> sessionPolicy["disallowedTools + canUseTool"] sessionPolicy --> sessionDriver["Claude Code Runtime Driver"] end

两条路径共享产品能力,但不共享同一个工具注册实现。原因是 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 被禁用时:

  1. 第一轮发现 ToolA
  2. 第二轮禁用 ToolB
  3. 第三轮禁用 ToolC

单次遍历是否足够取决于声明顺序,不动点算法则与工具注册顺序无关。

对于工具数量为 V、依赖边为 E 的小型注册表,该实现最坏约为 O(V × (V + E))。工具图通常很小,可读性与正确性优先于复杂的拓扑优化;若未来工具图大规模增长,再改为反向依赖图上的 BFS。

6. 关键算法三:工具延迟暴露与 token 预算

大型 MCP 生态会带来数百个工具 schema。将全部工具直接放入模型上下文会造成:

  • Prompt token 膨胀。
  • 模型在相似工具之间选择困难。
  • 首 token 延迟和调用成本上升。

Cherry Studio 使用 deferred exposition:将一部分工具从初始工具集移除,改为提供 tool_searchtool_inspecttool_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:验证、写入权威状态、恢复对应运行时

概念状态机:

stateDiagram-v2 [*] --> requested requested --> approved approved --> executing executing --> resolved requested --> denied denied --> resolved requested --> abandoned abandoned --> resolved

伪代码:

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)
  }
}

关键不变量:

  1. Renderer 不直接写数据库。
  2. 只有 Main 能从 awaiting-approval 恢复流。
  3. 对 DB anchor 的写入必须确认目标 approval part 仍存在。
  4. 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 的成熟标志不是"接入了很多工具",而是系统能够清楚回答:

  1. 当前模型为什么能看到这个工具,而看不到另一个?
  2. 该工具真正执行前还会经过哪些权限、目录和审批检查?
  3. MCP 失效、缓存未预热或工具列表变化时,系统如何降级?
  4. 用户能否在不破坏执行状态的前提下批准、拒绝、取消或恢复任务?
  5. 是否能审计一次执行的模型、工具、审批、成本与敏感数据边界?

一句话总结:

Harness 不负责让模型更聪明;它负责让模型能力在产品中变得可授权、可观察、可恢复且可承担责任。

继续阅读

相关推荐
AI 小老六1 小时前
Agent Runtime 如何用 Session、Memory、User Profile 和 Skill 实现外部学习
服务器·人工智能·学习·ai·架构·自动化
aqi001 小时前
15天学会AI应用开发(十九)使用LangGraph实现持久记忆功能
人工智能·python·大模型·ai编程·ai应用
又折桃枝换酒钱1 小时前
CycleChart:一个统一的基于一致性学习的双向图表理解与生成框架(翻译与解读)
人工智能·深度学习·学习
像风一样自由20201 小时前
14.时序异常检测入门到实战: 时序大模型:不训练,直接挑异常
人工智能·时序异常检测·时间序列模型
Asize1 小时前
无状态 Stateless:大模型为什么记不住你是谁
javascript·人工智能·架构
Luhui_Dev1 小时前
AI 写论文,你敢用吗?Google 用 CoE 给结论上证据链
人工智能·agent
起个名字好难啊这也被占用了1 小时前
浏览器里跑能操作DOM的智能体
人工智能
饼饼学习空间智能1 小时前
遥操作数据能否提升机器人自主化水平?详解模仿学习、仿真放大与Sim2Real闭环
人工智能·算法
Goodwin1 小时前
手机离线跑大模型ReactNative实战
人工智能·ai编程