OpenHarness源码研究-4-AgentLoop对话引擎与工具系统

type
Post
status
Published
date
Jun 30, 2026
slug
250630-openhasness-4
summary
从run_query的while循环出发,拆解Agent Loop的完整运转过程——消息模型、流式事件、工具执行、Runtime组装和System Prompt构建
tags
开发
category
技术分享
icon
password
 

前言

⚙ 拆解一次对话从用户输入到AI回复的完整链路:消息怎么建模、Agent Loop怎么循环、工具怎么执行、Runtime怎么组装、System Prompt怎么拼。

从debug说起

第2篇我们写了 oh -p "你是谁" 的 debug 配置。断点打在 run_print_mode() 里,跟着调用栈一路往下走,能看到这样一条链路:
第3篇分析了 API Client 层(_resolve_api_client_from_settings 那段),这篇讲剩下的:消息模型、Agent Loop、工具系统、Runtime 组装、System Prompt。

消息模型

不同 LLM 的 API 格式不一样,但引擎内部需要一套统一的消息表示。这就是 engine/messages.py 的职责。
四个 ContentBlock 覆盖了对话中的所有信息类型。role 只有 user 和 assistant 两种——system prompt 不在这里,它作为独立参数传给 API。
这套模型是 Anthropic Messages API 的原生格式。OpenAICompatibleClient 把它翻译成 OpenAI 格式(第3篇分析过),AnthropicApiClient 直接序列化:
消息模型决定了整个引擎的设计。如果把 system prompt 也当作一条消息、把 tool 也当作一种 role,那代码复杂度会成倍增加——OpenAI 就是这么做的,而 Anthropic 的设计更简洁。OpenHarness 选择了 Anthropic 格式作为内部规范,翻译工作全部丢给 OpenAICompatibleClient。

AgentLoop:while循环+tool_use检测

engine/query.pyrun_query() 是整个项目的核心。它做的事说起来简单:
用代码表达:
几个关键设计决策:
1. 单工具顺序 vs 多工具并发的选择
如果模型一次返回了多个 tool_use(比如同时读3个文件),用 asyncio.gather 并发执行。只返回一个时走顺序路径。这个分支不是无谓的优化——并发时需要等所有工具都完成才能通知 UI 更新,顺序时则不需要等。两种场景的 UI 事件发送时序不同。
2. max_turns 的硬截断
默认 200 轮。一次"turn" = 模型回复 + 可能的一次工具调用。200 轮对于大多数任务绰绰有余,但如果不设限,一个死循环的工具调用就能烧掉大量 token。这个截断是防御性的。
3. auto-compact:上下文太长怎么办
在每轮循环开始时检查:
上下文压缩分两步:先尝试 microcompact(把旧工具结果的 content 清掉换成占位文本,成本极低),如果还不够,再做 full compact(调 LLM 对旧消息做摘要,成本较高但有意义)。阈值是 AUTOCOMPACT_BUFFER_TOKENS = 13000,给模型预留的输出空间。

流式事件-引擎怎么通知UI

引擎在执行过程中产生 6 种事件:
UI 层只消费这些事件,不关心事件是怎么产生的。run_print_mode()run_repl() 处理同一套事件,只是渲染方式不同。这个设计和前端框架里的"状态管理"是一个思路——引擎是 store,事件是 action,UI 是 view。

工具系统-AI操控电脑的接口

tools/base.py 定义了工具的契约:
每个工具就是实现这 5 个东西。以文件读取为例:
is_read_only 是关键——它直接关联权限系统。读操作自动放行,写操作触发权限检查。
Bash 工具更复杂一些。它的 is_read_only 默认返回 False(没法静态判断一个 shell 命令是不是只读),所以总是需要权限确认,除非用户在 full_auto 模式下:
AgentTool 更有意思——工具里调子 Agent:
工具不再是简单的文件操作,而是可以递归地启动新的 Agent 进程。这就从"工具调用"升级到了"多 Agent 协作"。

Runtime组装-build_runtime的12步

ui/runtime.pybuild_runtime() 把所有零件装到一起:
这 12 步的依赖关系是单向的:settings 在最前面,engine 在最后面。RuntimeBundle 只是一个 dataclass,把所有东西打成一个包。后续 handle_line() 从这个包里取东西用。
handle_line() 是交互的核心——它判断用户输入是 slash 命令还是普通对话:

SystemPrompt-AI看到的第一段话

System Prompt 不是一段写死的文本,而是在 build_runtime_system_prompt() 里动态拼装的:
基础 prompt 本身就包含了环境信息——OS、Shell、日期、Git 分支等,由 get_environment_info() 自动探测。这样 AI 不用问"你是什么系统"就能直接给出正确的命令。
CLAUDE.md 是放在项目根目录的一个文件,用户在里面写项目约定和偏好。它会被原样注入 System Prompt。这和 GitHub Copilot 的 .github/copilot-instructions.md 是一个思路。

总结

  • Agent Loop 的核心就是一个 while 循环:模型说一句 → 有 tool_use 就执行 → 结果喂回去 → 继续
  • 单工具顺序执行,多工具 asyncio.gather 并发。分支的原因不是性能,是 UI 事件时序不同
  • max_turns=200 是防御性的硬截断,auto-compact 在每轮循环前检查是否需要压缩上下文
  • BaseTool 的 5 个契约方法构成了工具系统的基础,is_read_only 直接关联权限
  • RuntimeBundle 是会话的"零件包",12 步装配顺序是单向依赖
  • System Prompt 是动态拼装的:base + 环境 + skills + CLAUDE.md + 记忆

写到最后

notion image
是在往前走就好 bothsavage.github.io
 
notion image
 
Prev
OpenHarness源码研究-5-基础设施-配置/认证/权限/扩展
Next
OpenHarness源码研究-3-codex配置到输出对话
Loading...
Article List
talk is cheap
技术分享
万里长征
心情随笔
知行合一