@@ -0,0 +1,428 @@
# core/agent/ + adapters/llm 基础设施设计
**日期 ** 2026-07-06 · **状态 ** 已批准 · **涉及算法保真 ** #11 Agent Loop
---
## §1 设计决策总结
| 决策点 | 结论 | 理由 |
|--------|------|------|
| 同步 vs 异步 | **全异步 ** | 流式 SSE + asyncio.timeout 看门狗 + 未来 ARQ 扩展 |
| LLMProvider 返回值 | **完整 LLMResponse ** ( adapters 内部流式消费) | core 不需逐 token 处理;TTFT 是基础设施指标属 adapters 层 |
| thinking 剥离层 | **adapters 层统一剥离 ** | core 不感知 provider 差异;新增 provider 只改 adapters |
| 遥测写入 | **两层写入 ** | adapters: 原始 LLM 指标 → telemetry.db( GovernedLLMClient 自动);core: agent 行为轨迹通过 pluggy hook 由外部插件(如 HarnessLog 的 TracePlugin)写入 |
| LLMProvider 归属 | **core/protocols.py ** (共享端口) | agent、建树、诊断、进化都需要;避免 core 子包互相依赖 |
| ARQ 任务队列 | **砍掉(YAGNI) ** | CLI 驱动研究工具,asyncio 原生并发够用;跨进程场景靠重试退避自然降级 |
| adapters 组织 | **组合式 ** | 拆成独立可测组件;GovernedLLMClient 组合它们 |
---
## §2 文件清单与依赖方向
### 2.1 新建文件
| 文件 | 职责 |
|------|------|
| `core/protocols.py` | 共享端口:LLMProvider, VLMProvider, TelemetryRecorder |
| `core/agent/protocols.py` | Agent 专属端口:ToolDispatcher, AgentLoopSpec |
| `core/agent/types.py` | Step, LoopResult |
| `core/agent/loop.py` | AgentLoop 引擎 |
| `adapters/llm.py` | GovernedLLMClient(实现 LLMProvider,组合治理栈) |
| `adapters/streaming.py` | stream_with_liveness_timeouts() 三层看门狗 |
| `adapters/breaker.py` | CircuitBreaker(内存级) |
| `adapters/redis_cache.py` | RedisResponseCache( content-addressed) |
| `adapters/telemetry.py` | SQLiteTelemetryRecorder(实现 TelemetryRecorder) |
### 2.2 修改文件
| 文件 | 变更内容 |
|------|---------|
| `core/types.py` | 新增 LLMResponse dataclass |
| `ARCHITECTURE.md` | §3.1 接缝清单重构(共享/专属分类);§4 遥测新增 thinking + ttft_ms 字段;§5 治理栈从五层改为四层 |
| `CLAUDE.md` | 同步 §4.8 遥测字段、§4.9 治理栈层数 |
### 2.3 依赖方向
``` mermaid
flowchart TB
subgraph core
CP["core/protocols.py\nLLMProvider, VLMProvider\nTelemetryRecorder"]
CT["core/types.py\nLLMResponse"]
AP["core/agent/protocols.py\nToolDispatcher, AgentLoopSpec"]
AT["core/agent/types.py\nStep, LoopResult"]
AL["core/agent/loop.py\nAgentLoop"]
end
subgraph adapters
GL["adapters/llm.py\nGovernedLLMClient"]
ST["adapters/streaming.py\n三层看门狗"]
BR["adapters/breaker.py\nCircuitBreaker"]
RC["adapters/redis_cache.py\nRedisResponseCache"]
TL["adapters/telemetry.py\nSQLiteTelemetryRecorder"]
end
AL --> CP & AP & AT & CT
AP --> AT
GL -->|实现| CP
GL --> ST & BR & RC & TL & CT
TL -->|实现| CP
```
---
## §3 core/protocols.py — 共享端口
三个 `@runtime_checkable` Protocol。
### LLMProvider
``` python
class LLMProvider ( Protocol ) :
async def chat (
self ,
messages : list [ dict [ str , Any ] ] ,
* ,
session_id : str | None = None ,
parent_call_id : str | None = None ,
) - > LLMResponse : . . .
```
- `session_id` : epoch/step/question 关联,遥测链路追踪
- `parent_call_id` : agent step → LLM call 父子关系
- 无 model/temperature——实例配置,构造时确定
### VLMProvider
``` python
class VLMProvider ( Protocol ) :
async def chat_with_images (
self ,
messages : list [ dict [ str , Any ] ] ,
images : list [ str | Path ] ,
* ,
session_id : str | None = None ,
parent_call_id : str | None = None ,
) - > LLMResponse : . . .
```
与 LLMProvider 分离,不继承——职责不同,非所有 LLM 支持图片。返回同一 LLMResponse 类型。
### TelemetryRecorder
``` python
class TelemetryRecorder ( Protocol ) :
async def record_llm_call (
self ,
* ,
call_id : str ,
parent_call_id : str | None ,
session_id : str | None ,
model_name : str ,
provider : str ,
messages : str ,
response : str ,
thinking : str ,
prompt_tokens : int ,
completion_tokens : int ,
latency_ms : int ,
ttft_ms : float | None ,
max_inter_token_ms : float | None ,
cache_hit : bool ,
error : str | None ,
) - > None : . . .
```
比 ARCHITECTURE.md §4 原方案新增 `thinking` 、`ttft_ms` 、`max_inter_token_ms` 字段。全部 keyword-only。
---
## §4 core/types.py — LLMResponse
``` python
@dataclass ( frozen = True )
class LLMResponse :
content : str # 纯输出(thinking 已剥离)
thinking : str # thinking/reasoning 内容(无则空串)
model : str # 实际模型名
provider : str # API 端点标识
prompt_tokens : int
completion_tokens : int
latency_ms : int # 总延迟
ttft_ms : float | None # 首 token 延迟(缓存命中时 None)
max_inter_token_ms : float | None # 最大 token 间隔(缓存命中时 None)
cache_hit : bool
call_id : str # UUID,调用唯一标识
```
- `frozen=True` :响应是不可变事实
- `call_id` 随响应带出,core 层用于关联 agent step → LLM call
- VLMProvider 返回同一类型,不单独定义 VLMResponse
---
## §5 core/agent/ — AgentLoop 可提取内核
### 5.1 core/agent/types.py
``` python
@dataclass
class Step :
thought : str # thinking/reasoning 内容
reflect : dict [ str , Any ] # 结构化反思
plan : dict [ str , Any ] # 结构化计划
tool_call : dict [ str , Any ] # {"tool": name, "args": {...}}
tool_output : str # 工具执行结果
raw_content : str # LLM 原始 JSON 输出
call_id : str # 关联 LLMResponse.call_id
@dataclass
class LoopResult :
result : dict [ str , Any ] | None = None
steps : list [ Step ] = field ( default_factory = list )
steps_used : int = 0
token_usage : dict [ str , int ] = field (
default_factory = lambda : { " prompt_tokens " : 0 , " completion_tokens " : 0 }
)
stop_reason : str = " finished "
# "finished" | "error" | "parse_error" | "budget_exceeded"
```
vs TRM4: Step 新增 `call_id` ,其余结构保持一致(算法保真 #11 )。
### 5.2 core/agent/protocols.py
``` python
class ToolDispatcher ( Protocol ) :
async def dispatch (
self , tool_name : str , args : dict [ str , Any ] , * , context : dict [ str , Any ]
) - > str : . . .
```
vs TRM4:从 `Callable[[str, dict], str]` 升级为 Protocol;新增 `context` ; async 化。无效工具名抛 `ValueError` 。
**AgentLoopSpec ** — pluggy hookspec,四个 async 生命周期 hook:
| hook | 签名 | 说明 |
|------|------|------|
| `before_step` | `(iteration, messages) -> None` | LLM 调用前 |
| `after_tool` | `(iteration, step) -> str \| None` | 工具执行后;返回非 None 注入反馈 |
| `after_step` | `(iteration, messages) -> None` | 整轮结束后 |
| `on_finish` | `(result: LoopResult) -> None` | 循环终止时 |
**Agent 行为轨迹记录机制 ** :AgentLoop 本身不写日志,行为轨迹(step/thought/tool_call)通过 pluggy hook 暴露给外部插件。`app/harness/` 的 `TracePlugin` 注册为 pluggy 插件,在 `after_step` /`on_finish` 中写入 `HarnessLog` 。这实现了"两层遥测"中 core 层的职责——机制在 core,策略在 app。
### 5.3 core/agent/loop.py — AgentLoop
``` python
class AgentLoop :
def __init__ (
self ,
llm : LLMProvider ,
max_steps : int ,
max_retries : int = 3 ,
) - > None : . . .
async def run (
self ,
system_prompt : str ,
user_prompt : str ,
tool_dispatcher : ToolDispatcher ,
plugins : list [ Any ] | None = None ,
* ,
session_id : str | None = None ,
) - > LoopResult : . . .
```
核心循环保真 TRM4 算法 #11 :
```
messages = [system, user]
while steps_used < max_steps:
await hooks.before_step(iteration, messages)
response = await llm.chat(messages, session_id=session_id,
parent_call_id=last_call_id)
累加 token_usage
parsed = _parse_response(response) # json_repair 兜底
if 连续 parse 失败 > max_retries: break(parse_error)
step = Step(..., call_id=response.call_id)
tool_output = await tool_dispatcher.dispatch(tool, args, context=...)
feedback = await hooks.after_tool(iteration, step)
组装 messages(工具结果 + 可选 feedback)
await hooks.after_step(iteration, messages)
if tool == "submit_answer": break(finished)
await hooks.on_finish(result)
```
保真点:`json_repair.repair_json()` 、解析失败纠正 prompt、`submit_answer` 终止、pluggy hook 生命周期。
**依赖说明 ** : `core/` 允许依赖 `stdlib` 、`typing` 、`pluggy` 、`json_repair` 。`json_repair` 是 AgentLoop 算法保真 #11 的必要依赖(TRM4 已使用),作为 core 允许的第三方库显式列入。
---
## §6 adapters/ — 四层治理栈
### 6.1 adapters/streaming.py — 三层看门狗
``` python
class StreamLivenessTimeout ( Exception ) :
kind : str # "ttft" | "inter_token" | "total"
elapsed_s : float
first_token_seen : bool
async def stream_with_liveness_timeouts (
source : AsyncIterator [ tuple [ bool , str ] ] ,
* , ttft_s : float , inter_token_s : float , total_s : float ,
) - > AsyncIterator [ tuple [ bool , str ] ] : . . .
```
- 入参 `(is_content, text)` ——thinking token( `is_content=False` )刷新看门狗但不计入 content
- `min(layer_budget, remaining_total)` 合并三层为单循环
- 只 wrap `__anext__` ,不 wrap `yield` (防取消泄漏)
- `asyncio.timeout()` + `cm.expired()` 区分本层 vs 上游超时
参考:CHSAnalyzer2 `app/providers/streaming.py`
### 6.2 adapters/breaker.py — 熔断器
``` python
class CircuitBreaker :
def __init__ ( self , fail_threshold : int , cooldown_s : float ) - > None : . . .
def is_open ( self , now : float ) - > bool : . . .
def record_failure ( self , now : float ) - > None : . . .
def record_success ( self ) - > None : . . .
def force_open ( self , now : float ) - > None : . . .
```
- 注入 `now` ,纯确定性可测试
- 内存级单实例,半开探针自动恢复
- `force_open` 用于 401/403 直接熔断
参考:CHSAnalyzer2 `app/providers/breaker.py`
### 6.3 adapters/redis_cache.py — 响应缓存
``` python
class RedisResponseCache :
def __init__ ( self , redis : redis . asyncio . Redis , ttl_s : int ) - > None : . . .
async def get ( self , model : str , messages : list [ dict ] ) - > LLMResponse | None : . . .
async def set ( self , model : str , messages : list [ dict ] , response : LLMResponse ) - > None : . . .
```
- content-addressed: `key = sha256(model + json_canonical(messages))`
- Redis 不可用时静默降级(`get` 返回 None, `set` 吞异常 + 日志告警)
- TTL 通过 `.env` 工程配置管理
### 6.4 adapters/telemetry.py — 遥测记录
``` python
class SQLiteTelemetryRecorder :
def __init__ ( self , db_path : Path ) - > None : . . .
async def record_llm_call ( self , * , . . . ) - > None : . . .
```
- 实现 `TelemetryRecorder` Protocol
- SQLite 写入通过 `asyncio.to_thread()` 桥接
- `llm_calls` 表字段与 Protocol 一一对应
### 6.5 adapters/llm.py — GovernedLLMClient
``` python
class GovernedLLMClient :
def __init__ (
self , * ,
model : str , base_url : str , api_key : str ,
provider : str , # "deepseek" | "qwen" | "unknown"
thinking : bool ,
breaker : CircuitBreaker ,
cache : RedisResponseCache | None ,
telemetry : TelemetryRecorder ,
timeout_s : float ,
ttft_timeout_s : float | None ,
inter_token_timeout_s : float | None ,
max_retries : int ,
retry_base_delay_s : float ,
retry_max_delay_s : float ,
) - > None : . . .
async def chat ( self , messages , * , session_id = None , parent_call_id = None ) - > LLMResponse : . . .
```
`chat()` 编排伪代码:
```
① breaker.is_open(now)? → raise CircuitOpenError
② call_id = uuid4() # 每次调用都生成,含缓存命中
③ cached = cache.get(model, messages)?
telemetry.record_llm_call(call_id, ..., cache_hit=True) # 缓存命中也记遥测
return cached._replace(call_id=call_id, cache_hit=True)
④ 重试循环:
started = monotonic()
stream = httpx_client.stream(POST /chat/completions, stream=True)
sse_deltas = _iter_sse_deltas(stream)
guarded = stream_with_liveness_timeouts(sse_deltas, ttft, inter_token, total)
content, thinking, ttft_ms, max_itoken_ms, tokens = _consume(guarded)
breaker.record_success()
except transient → backoff + breaker.record_failure()
except 401/403 → breaker.force_open(); raise
⑤ response = LLMResponse(content, thinking, ..., call_id,
ttft_ms, max_itoken_ms, cache_hit=False)
⑥ cache.set(model, messages, response)
⑦ telemetry.record_llm_call(call_id, ..., max_inter_token_ms, cache_hit=False)
⑧ return response
# 异常路径同样记遥测(error=str(e))
```
provider 差异封装为私有方法:
- `_build_thinking_body()` — DeepSeek/Qwen thinking 参数差异
- `_strip_thinking()` — Qwen `<think>` 标签剥离 vs DeepSeek `reasoning_content` 字段提取
---
## §7 配置归属
| 参数 | 归属 | 载体 |
|------|------|------|
| `*_API_KEY` , `*_BASE_URL` , `*_MODEL` | 工程配置 | `.env` |
| `REDIS_URL` | 工程配置 | `.env` |
| `LLM_TIMEOUT` | 工程配置 | `.env` |
| `LLM_TTFT_TIMEOUT` | 工程配置 | `.env` (新增) |
| `LLM_INTER_TOKEN_TIMEOUT` | 工程配置 | `.env` (新增) |
| `LLM_MAX_RETRIES` , `LLM_RETRY_BASE_DELAY` , `LLM_RETRY_MAX_DELAY` | 工程配置 | `.env` |
| `LLM_CIRCUIT_BREAKER_THRESHOLD` , `LLM_CIRCUIT_BREAKER_COOLDOWN` | 工程配置 | `.env` |
| `REDIS_CACHE_TTL` | 工程配置 | `.env` (新增) |
所有参数均为系统运行所需、少变——不会在实验中反复扫动,归工程配置。
---
## §8 ARCHITECTURE.md / CLAUDE.md / 配置文件同步变更
> **时机**:规范同步在实现开始前完成(作为实现计划的第一个任务),而非实现结束后补。
| 文档 | 章节 | 变更 |
|------|------|------|
| ARCHITECTURE.md §2.3 | 目录结构 | `core/` 下新增 `protocols.py` ; `adapters/` 新增 `streaming.py` 、`breaker.py` |
| ARCHITECTURE.md §2.4 | 依赖方向 | `core/` 允许依赖扩展为:标准库、typing、pluggy、json_repair |
| ARCHITECTURE.md §3.1 | 核心端口 | LLMProvider/VLMProvider/TelemetryRecorder 从 `core/agent/protocols.py` 和 `core/evolution/protocols.py` 上提到 `core/protocols.py` ; ToolDispatcher/AgentLoopSpec 留在 `core/agent/protocols.py` |
| ARCHITECTURE.md §4 | 遥测规范 | 新增 `thinking` 、`ttft_ms` 、`max_inter_token_ms` 字段 |
| ARCHITECTURE.md §5 | 韧性治理 | 五层改四层(砍掉 ARQ,理由:CLI 研究工具无需投递式队列,asyncio 原生并发够用);新增流式三层看门狗说明 |
| CLAUDE.md §4.8 | Agent 遥测 | 同步新增字段(thinking、ttft_ms、max_inter_token_ms) |
| CLAUDE.md §4.9 | LLM 韧性 | 同步四层治理栈 + 三层看门狗;删除 ARQ 层说明 |
| CLAUDE.md §5 | 项目结构 | `core/` 下新增 `protocols.py` |
| `.env.example` | — | 新增 `LLM_TTFT_TIMEOUT` 、`LLM_INTER_TOKEN_TIMEOUT` 、`REDIS_CACHE_TTL` |
---
## §9 被拒方案
| 被拒方案 | 理由 |
|---------|------|
| 同步 AgentLoop | 流式 SSE + asyncio.timeout 看门狗无法在同步模型中干净实现 |
| LLMProvider 返回流式迭代器 | core 不需逐 token 处理;TTFT 是基础设施指标不应泄漏到 core |
| 双接口(chat + chat_stream) | YAGNI——当前无实时展示需求 |
| core 层解析 thinking | 耦合 provider 特征,破坏可提取性 |
| 仅 adapters 层写遥测 | 漏掉 agent 行为级轨迹(step/thought/tool_call) |
| LLMProvider 留在 core/agent/protocols.py | evolution/tree 等非 agent 消费者被迫依赖 core/agent/,破坏子包独立性 |
| 保留 ARQ | CLI 研究工具不需要投递式任务队列;跨进程限流靠重试退避自然降级 |
| 单体 GovernedLLMClient | 500+ 行单文件,组件无法独立测试 |