Files
Video-Tree-TRM5/research-wiki/designs/2026-07-06-core-agent-adapters-llm-design.md
T
iomgaa f541c4047a design: core/agent/ + adapters/llm 基础设施设计
全异步 AgentLoop + 四层治理栈(超时→重试→熔断→缓存)+ 流式三层看门狗。
LLMProvider/VLMProvider/TelemetryRecorder 上提到 core/protocols.py 作为共享端口。
经 Codex 独立审查,修复 7 项发现(含缓存遥测、inter_token 指标、json_repair 依赖等)。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-06 22:06:48 -04:00

429 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.dbGovernedLLMClient 自动);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` | RedisResponseCachecontent-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 TRM4Step 新增 `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+ 行单文件,组件无法独立测试 |