# 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+ 行单文件，组件无法独立测试 |
