# auto_put_ad_mini：不走 LLM 的规则盘点（分析报告）

## Context

用户想理清：`examples/auto_put_ad_mini` 里一条广告从数据进来到决策输出，**除了"零消耗"这一条规则外，还有哪些规则会让它绕过 LLM 推理**。目的是让运营/开发方能明确知道"什么数据根本走不到 LLM，什么数据 LLM 的输出会被事后覆盖"。

这是一个**纯分析任务，不修改任何代码**。结论写入本文件即可。

---

## 一、走 LLM 的流程是什么（一条广告如何被"喂"给 LLM）

走 LLM 就是一条广告被放进 `need_review_ads` 列表 → `apply_decisions` 在这份列表上拿 LLM 的 JSON 决策。流程分四步：

### 步骤 1：数据就绪（前置依赖）
由 `execute_once.py` 按顺序跑：
```
fetch_creative_data → merge_creative_data → calculate_roi_metrics → get_ads_for_review
                                              ↑
                                   产出 metrics CSV（含 动态 ROI (7日均值)、ad_age_days、
                                   cost_7d_avg、stable_spend_days_30d、audience_tier、
                                   tier_roi_p25/p50/p75、tier_fission_mean 等）
```

### 步骤 2：计算 5 个候选标记（`ad_decision.py:405-492`）
对 metrics 里每条广告，用规则计算这 5 个 bool：

| 标记 | 含义 | 条件（核心） |
|------|------|------|
| `roi_low` | ROI 偏低，需评估关停 | `dynamic_roi_7d < channel_roi_p50 × 0.8` 且 `yesterday_cost ≥ 300` 且 `ad_age > 3` |
| `decay_signal` | 衰退（调过价/换过创意但仍低迷） | `stable_days ≥ 7` 且 `cost_7d_avg < 100` 且（7 天内调过价 或 换过创意） |
| `bid_up_candidate` | 可提价（A 或 B 命中即可） | **A 唤醒沉默**：`ad_age ∈ [4,7]` 且 `cost_7d_avg < 10` 且 CTR ≥ 同类均值×0.80<br>**B 优质放量**：`ad_age ∈ [4,7]` 且 `dynamic_roi_7d > channel_roi_p50 × 1.05` 且 `cost_7d_avg < 1000` 且裂变 > 同类均值×1.10 且 CTR ≥ 同类均值×0.80 |
| `bid_down_candidate` | 可降价 | `channel_roi_p50 × 0.75 ≤ dynamic_roi_7d < channel_roi_p50 × 0.9` 且 `cost_7d_avg ≥ 500` 且裂变 < 同类均值×0.90 |
| `scale_up_candidate` | 可扩量 | `ad_age > 7` 且 `stable_days ≥ 7` 且 `cost_7d_avg > 1000` 且 `dynamic_roi_7d ≥ channel_roi_p50 × 0.9` |

另外有一个升级路径 `persistent_low_roi`（`ad_decision.py:464-482`）：ROI 在 0.75~0.90 之间、距上次降价 ≥ 7 天 → 把 `roi_low` 从 False 升级为 True（从"降价候选"升级为"关停候选"）。

### 步骤 3：两道过滤（前置过滤，不经 LLM）
标记算完后，**进 LLM 前**还要过两道硬规则：

1. **消耗稳定性前置门控**（`ad_decision.py:495-504`）：成熟期（>7天）但 `stable_days < 7` → 清空 `roi_low/decay/bid_down`。
2. **年龄保护**（`ad_decision.py:510-547`）：
   - `ad_age ≤ 3`：**整条跳过**，不进 LLM（归入正常运行）。
   - `ad_age ≤ 7`：清空 `roi_low/decay/bid_down`；若清完后没有 `bid_up/scale_up` 任何一个也跳过。

### 步骤 4：进入 LLM 的门槛（`ad_decision.py:556`）
最终条件：**5 个候选标记里有任意 1 个为 True**
```python
if roi_low or decay_signal or bid_up_candidate or bid_down_candidate or scale_up_candidate:
    need_review_ads.append(ad_dict)   # 进 LLM
```

这时会构造一个 `ad_dict`（`ad_decision.py:558-589`），除基础指标外还带上**同人群包的统计对比基准**（`tier_roi_p25/p50/p75/mean`、`tier_fission_mean`、`tier_ctr_mean`、`roi_valid_days` 等），给 LLM 做"同类对比"。

### 步骤 5：LLM 决策（由 Agent Runner 驱动）
Agent 读 Skills（roi_strategy.md / guardrail_rules.md / ad_domain.md）+ `need_review_ads` 里的广告列表，对**每条广告**逐一推理产出：
```json
{
  "ad_id": ...,
  "action": "pause|bid_down|bid_up|scale_up|observe|hold",
  "dimension": "...",
  "reason": "自然语言解释",
  "confidence": "high|medium|low",
  "recommended_change_pct": 0.05
}
```
然后调用 `apply_decisions(decisions=[...], metrics_csv=...)`。

### 步骤 6：LLM 输出后再过滤（仍可能被改写）
`apply_decisions` 会把 LLM 的 JSON 与 metrics CSV merge，并执行兜底：
- 冷启动/成长期的 bid_down/pause/bid_up 一律强制改为 `observe`（`ad_decision.py:831-858`）
- 与规则产出的 `zero_spend`、`hold` 合并成最终决策表
- 最终再走一遍 `guardrails.py` 护栏（block/钳位）

---

## 一补、进 LLM 的"最小充分条件"速查

一条广告能进 LLM ⇔ 同时满足：

1. **不是"成熟期 (>7天) + 零消耗"**（成熟期 + cost_7d_avg<10 → 规则直接 pause；4-7天 + 零消耗仍可进 LLM 走"提价分支A"）
2. `ad_age > 3`（过了冷启动）
3. 不属于"成熟期但消耗不稳定"
4. 至少命中以下之一：
   - （成熟期 >7 天）ROI 低且昨日消耗 ≥300 → `roi_low`
   - （成熟期 >7 天）消耗低迷但调过价/换过创意 → `decay_signal`
   - （4-7 天）任一分支 → `bid_up_candidate`：
     - **A**：日均消耗 < 10元 + CTR 正常（唤醒沉默）
     - **B**：ROI 高 + 消耗<1000 + 裂变/CTR 正常（优质放量）
   - （任何年龄>3，不在早期成长期）ROI 中低 + 消耗≥500 + 裂变偏低 → `bid_down_candidate`
   - （成熟期 >7 天）ROI 正常 + 高消耗稳定 → `scale_up_candidate`

反过来，以下广告**一定不会走 LLM**（属于规则自动判决或被 hold）：
- **成熟期（>7天）+ 零消耗（cost_7d_avg < 10）** → 规则 pause
- **冷启动期（ad_age ≤ 3）+ 零消耗** → hold 保护
- 冷启动期（ad_age ≤ 3）所有广告（不区分消耗）
- 昨日消耗 < 300 且 ROI 低（低消耗广告的 ROI 不具统计意义）
- 成熟期但 stable_days < 7 且所有信号都被清
- 5 个候选都是 False（ROI 在同类中轴附近、消耗平稳）
- 状态已经是 AD_STATUS_SUSPEND

---

## 二、整体分流结构（三阶段）

```
数据 → 【阶段 1】get_ads_for_review  分三类：零消耗 / 候选 / 正常
            │
            ├─ 零消耗  → 规则产出 pause（不走 LLM）
            ├─ 候选    → 进 LLM 推理
            └─ 正常    → 规则产出 hold（不走 LLM）
            ↓
       【阶段 2】apply_decisions  合并 LLM 输出 + 规则决策 + 兜底过滤
            ↓
       【阶段 3】guardrails  护栏拦截/钳位（LLM 后过滤）
```

**走 LLM 的只有"候选"这一条线**。其他都是规则。

关键文件：
- `Agent/examples/auto_put_ad_mini/tools/ad_decision.py`
- `Agent/examples/auto_put_ad_mini/tools/guardrails.py`
- `Agent/examples/auto_put_ad_mini/config.py`

---

## 二、阶段 1 — `get_ads_for_review()` 里的分流规则

### A. 零消耗类（规则直接出决策，不走 LLM）

| # | 规则 | 条件 | 动作 | 位置 |
|---|------|------|------|------|
| 1.1a | **零消耗 + 冷启动保护** | `cost_7d_avg < 10` 且 `ad_age ≤ 3` | 归入"正常运行"（hold），**完全保护不评估** | `ad_decision.py:394-402` |
| 1.1b | **零消耗 + 早期成长期放行** | `cost_7d_avg < 10` 且 `4 ≤ ad_age ≤ 7` | **不归入 hold，放行进入候选评估**（命中"提价分支A"则提价唤醒） | `ad_decision.py:403-409` |
| 1.2 | **零消耗 + 长期不跑** | `cost_7d_avg < 10` 且 `ad_age > 7` | **规则产出 pause**，进 `zero_spend_ads` | `ad_decision.py:410-417` |

涉及常量：`NO_SPEND_THRESHOLD=10`、`EARLY_GROWTH_DAYS=7`。

### B. 年龄保护类（直接 skip LLM 或清除负向信号）

| # | 规则 | 条件 | 动作 | 位置 |
|---|------|------|------|------|
| 1.3 | **冷启动期极度保护** | `ad_age ≤ 3` | `continue` 跳过，归入"正常运行"（hold） | `ad_decision.py:512-518` |
| 1.4 | **早期成长期清除负向** | `4 ≤ ad_age ≤ 7` 且有 `roi_low/decay/bid_down` 任一 | 强制把 `roi_low/decay_signal/bid_down_candidate` 全置 False；若没有提价/扩量候选 → 直接 skip | `ad_decision.py:522-547` |

涉及常量：`COLD_START_DAYS=3`、`EARLY_GROWTH_DAYS=7`。

### C. 数据质量类（不满足消耗门槛就不评估）

| # | 规则 | 条件 | 动作 | 位置 |
|---|------|------|------|------|
| 1.5 | **ROI 低消耗门槛** | 即使 `dynamic_roi_7d < channel_roi_p50 × 0.8`，若 `yesterday_cost < 300` 或 `ad_age ≤ 3` → **`roi_low` 置 False** | 不触发 LLM 评估，归入"正常运行" | `ad_decision.py:405-410` |
| 1.6 | **成熟期消耗不稳定清负向** | `ad_age > 7` 且 `stable_days < 7` 且有负向标记 | 强制把 `roi_low/decay/bid_down_candidate` 全置 False | `ad_decision.py:495-504` |

涉及常量：`ROI_LOW_MIN_YESTERDAY_COST=300`、`STABLE_SPEND_THRESHOLD=100`。

### D. 候选门控

| # | 规则 | 条件 | 动作 | 位置 |
|---|------|------|------|------|
| 1.7 | **无候选即 hold** | 前面所有标记清洗后 `roi_low / decay_signal / bid_up_candidate / bid_down_candidate / scale_up_candidate` **全为 False** | 不进 LLM，归入"正常运行"（hold） | `ad_decision.py:556` |

**换言之：能进 LLM 的前提 = 至少带一个候选标记，并且在上面 B/C 类没被洗掉。**

---

## 三、阶段 2 — `apply_decisions()` 的补洞与后过滤

这一层既会**补充规则决策**（给没进 LLM 的广告兜底动作），也会**覆盖 LLM 的错误输出**（兜底检查）。

| # | 规则 | 条件 | 动作 | 位置 |
|---|------|------|------|------|
| 2.1 | **零消耗再检** | `cost_7d_avg < 10` 且 `ad_age > 7`（无论 LLM 有没有给） | 强制 `action=pause, dimension=长期零消耗, source=规则判断` | `ad_decision.py:782-805` |
| 2.2 | **冷启动兜底覆盖** | LLM 对 `ad_age ≤ 3` 的广告给了 `bid_down / pause / bid_up` | **强制改写 `action=observe`**，并打 `logger.error("⚠️ 兜底检查触发！")` | `ad_decision.py:831-844` |
| 2.3 | **早期成长期兜底覆盖** | LLM 对 `4 ≤ ad_age ≤ 7` 的广告给了 `bid_down / pause` | 强制改写 `action=observe`，打 error 日志 | `ad_decision.py:845-858` |
| 2.4 | **正常运行自动 hold** | 广告既不在 `zero_spend` 也不在 `need_review` | 自动补一条 `action=hold`；若 `ad_age ≤ 3` 则 `dimension=冷启动保护`，否则 `正常运行` | `ad_decision.py:865-903` |
| 2.5 | **已暂停广告过滤** | `ad_status == AD_STATUS_SUSPEND` | 从决策表中 drop 掉 | `ad_decision.py:948-964` |

**2.2 / 2.3 是对 LLM 输出的硬约束**：即使 LLM 推理错了，阶段 2 也会把不符合年龄保护的动作改成 observe。

---

## 四、阶段 3 — 护栏层 `guardrails.py`

护栏在决策下发前再过一遍，可 `block`（改为 hold）或 `modified`（钳位数值）。

| # | 护栏 | 触发条件 | 结果 | 位置 |
|---|------|---------|------|------|
| 3.1 | **冷启动护栏** | `age ≤ 3` 且 action∈{pause, bid_down, bid_up}；或 `4-7 天` 且 action∈{pause, bid_down} | **block → hold** | `guardrails.py:208-241` |
| 3.2 | **数据新鲜度** | `_data_date` 距今 > 96h 且 action ≠ hold | **block → hold** | `guardrails.py:249-275` |
| 3.3 | **出价边界** | 推荐出价 < 0.05 元 或 > 1.00 元 | **modified**（钳位到边界值） | `guardrails.py:283-321` |
| 3.4 | **调幅范围** | 提价不在 5-10%、降价不在 3-5% | **modified**（钳位到区间内） | `guardrails.py:329-371` |
| 3.5 | **频率限制** | 当日调整 ≥ 2 次 / 距上次 < 6h / 日累计调幅 > 20% | **block → hold** | `guardrails.py:379-419` |
| 3.6 | **单日操作上限** | 累计操作数 ≥ `MAX_DAILY_OPS` | **block** | `guardrails.py:420+` |
| 3.7 | **Dry-run 模式** | `DRY_RUN_MODE=True` | **标记 dry_run**，不真正执行（不改 action） | `guardrails.py:420+` |

涉及常量：`BID_FLOOR_YUAN=0.05`、`BID_CEILING_YUAN=1.00`、`BID_UP_MIN/MAX_PCT=0.05/0.10`、`BID_DOWN_MIN/MAX_PCT=0.03/0.05`、`MAX_ADJUSTMENTS_PER_AD_PER_DAY=2`、`MIN_ADJUSTMENT_INTERVAL_HOURS=6`、`MAX_DAILY_CUMULATIVE_CHANGE_PCT=0.20`、`DATA_FRESHNESS_MAX_HOURS=96`。

---

## 五、按"为什么不走 LLM"维度归纳

把所有规则重新组织成 5 类，便于记忆：

### 1) 零消耗直接判定（2 条）
- `1.1`：低消耗 + 年龄≤7 → hold（保护）
- `1.2`、`2.1`：低消耗 + 年龄>7 → pause（规则关停）

### 2) 年龄保护（5 条）
- `1.3`、`3.1`：冷启动 ≤3 天 → 任何动作都被阻断，只能 hold
- `1.4`、`2.3`、`3.1`：成长期 4-7 天 → 不允许 bid_down / pause，越界全部改 observe 或 block

### 3) 数据质量门槛（3 条）
- `1.5`：昨日消耗 <300 元、年龄 ≤3 → 不被判为 "roi_low"
- `1.6`：成熟期稳定天数 <7 → 清除所有负向信号
- `3.2`：数据 >96 小时 → block

### 4) 候选门控（1 条）
- `1.7`：所有候选标记都为 False → 默认 hold，不进 LLM

### 5) 状态过滤 / 操作频次 / 边界钳位（5 条）
- `2.5`：已暂停广告整体移出决策表
- `3.3`、`3.4`：出价和调幅钳到业务区间
- `3.5`、`3.6`：频次与日操作上限
- `3.7`：干运行模式

---

## 六、关键洞察

1. **LLM 实际能看到的广告集合很小**：必须同时满足 — 不是零消耗、年龄在 4 天以上、昨日消耗达标（或有提价信号）、消耗稳定、至少带一个候选标记。
2. **年龄保护是贯穿三阶段的冗余防御**：阶段 1 排除 → 阶段 2 兜底改写 + error 日志 → 阶段 3 护栏 block。任一层漏掉都能被下一层兜住。
3. **"兜底检查触发"是个错误信号**：CLAUDE.md 明确说阶段 2 的 2.2/2.3 理论上不应触发，一旦触发说明阶段 1 的年龄保护漏了。可以靠 `grep "兜底检查触发" outputs/decision_log_*.log` 监控。
4. **`source=规则判断` 标签**是一个重要的可观测字段：报告里凡带这个标签的都不是 LLM 出的决策。
5. **硬约束的优先级**：规则 > LLM。即使 LLM 说 pause，只要碰到 2.2/2.3/3.1 这类硬规则，最终一定会被改写。

---

## 七、验证方法（只读检查，不改代码）

```bash
cd Agent/examples/auto_put_ad_mini

# 1. 查看最近一次运行中，各分类广告数量
grep -E "零消耗|候选|正常运行|need_review|zero_spend" outputs/decision_log_*.log | tail -30

# 2. 查 LLM 后过滤是否触发（应该永远为 0）
grep "兜底检查触发" outputs/decision_log_*.log | wc -l

# 3. 看护栏拦截记录
grep -E "blocked|modified" outputs/execution_log/*.json | head

# 4. 报表里的决策来源分布
python3 -c "import pandas as pd; df=pd.read_csv(sorted(__import__('glob').glob('outputs/reports/decisions_*.csv'))[-1]); print(df.groupby(['source','action']).size())"

# 5. 核对代码行号是否与报告一致
sed -n '370,560p' tools/ad_decision.py
sed -n '780,910p' tools/ad_decision.py
sed -n '200,420p' tools/guardrails.py
```

---

## 八、结论（一句话版）

> 除"零消耗"外，还有 **年龄保护（冷启动 ≤3 / 成长期 4-7）**、**数据质量门槛（昨日消耗<300、稳定天数<7、数据>96h）**、**候选门控（无任何候选标记）**、**状态过滤（已 SUSPEND）**、**护栏约束（出价边界、调幅范围、频率上限、日累计、干运行）**，合计 17 条规则让一条广告绕开 LLM、或覆盖 LLM 的输出。

---

*本文件为分析报告，不涉及代码改动。*

---

## 附录 A：运行时 Trace 可视化（框架原生支持）

确认：**框架原生支持运行时实时可视化**，不是只能事后看。组件分三层：

### A.1 后端：FastAPI + WebSocket（`Agent/api_server.py`）

启动命令（默认监听 `0.0.0.0:8000`）：
```bash
cd Agent
python3 api_server.py
```

提供的接口（聚合在 `agent/trace/` 下）：

| 类型 | 路由 | 说明 |
|------|------|------|
| REST | `GET /api/traces` | 列表（含运行中 trace） |
| REST | `GET /api/traces/{id}` | 单 trace 详情（GoalTree + Messages + Sub-Traces） |
| REST | `GET /api/traces/running` | 正在跑的 trace |
| REST | `POST /api/traces` | 启动 / 停止 / 反思 |
| REST | `GET /api/experiences` | 经验沉淀 |
| REST | `GET /api/examples` | 示例项目枚举 |
| **WS** | `ws://host:8000/api/traces/{id}/watch?since_event_id=N` | **实时推送**（断线续传） |
| WS | `ws://host:8000/logs` | 实时日志流 |

WebSocket 事件类型（`agent/trace/websocket.py:41-107`）：
- `connected` — 连接时返回 `goal_tree` + `sub_traces` 快照 + `is_running`
- `goal_added` / `goal_updated` — 目标树节点增加或状态变化
- `message_added` — 新 assistant/tool message（含 `affected_goals`）
- `sub_trace_started` / `sub_trace_completed` — 子 agent 生命周期
- `trace_completed` — 整条 trace 执行结束

断线续传靠 `since_event_id` 参数，服务端从 `events.jsonl` 补发缺失事件（最多 100 条）。

### A.2 前端：React（`Agent/frontend/react-template/`）

已经实现了完整可视化 UI：

```
src/components/
├── AgentControlPanel     # 启停 agent
├── FlowChart             # 目标树 + 流程图（主 trace + sub trace 嵌套）
├── MainContent           # 主视图
├── DetailPanel           # 单条 message / tool 调用的入参出参详情
├── TopBar
├── CreateKnowledgeModal / KnowledgeFeedbackModal  # 知识沉淀
└── ImagePreview
```

启动：
```bash
cd Agent/frontend/react-template
npm install           # 首次
npm run dev           # Vite dev server（默认 3000 或 5173）
```

### A.3 Trace 文件落盘（即使不开前端也能直接读）

每次 `execute_once.py` 运行都会写到：
```
examples/auto_put_ad_mini/.trace/<trace_id>/
├── meta.json          # trace 元数据（status、时间、config）
├── goal.json          # GoalTree 结构
├── events.jsonl       # 事件流（WebSocket 的来源）
├── messages/          # LLM 消息体（assistant / tool / user）
└── model_usage.json   # token 消耗
```

子 agent 的 trace 走 `<parent>@delegate-时间戳-序号` 或 `<parent>@explore-时间戳-序号` 命名，在同一目录下同级落盘，前端会自动串成嵌套树。

### A.4 配合 auto_put_ad_mini 的一个注意点

`api_server.py:62` 写死 `FileSystemTraceStore(base_path=".trace")`——按**启动时的工作目录**找 trace。而 mini 项目的 trace 默认写到 `examples/auto_put_ad_mini/.trace`（`config.py:59` 的 `TRACE_STORE_PATH=".trace"`）。三种做法任选：

1. 在 `examples/auto_put_ad_mini/` 目录下启动 `api_server.py`
2. 改 `api_server.py` 的 `base_path` 指向 mini 的 `.trace/`
3. 软链接：`ln -s ../../examples/auto_put_ad_mini/.trace/* Agent/.trace/`

### A.5 实时可视化推荐姿势（三终端）

```bash
# 终端 A — 起 API（从 mini 项目目录启动，base_path 指到它的 .trace）
cd Agent/examples/auto_put_ad_mini
python3 ../../api_server.py

# 终端 B — 起前端
cd Agent/frontend/react-template && npm run dev
# 浏览器打开 http://localhost:5173 （或 3000）

# 终端 C — 跑 Agent，就能在浏览器里实时看流程图推进
cd Agent/examples/auto_put_ad_mini
.venv/bin/python3 execute_once.py
```

跑起来后浏览器里能看到：
- 主 agent 的目标树节点逐个亮起
- 每个 tool 调用的入参 / 出参 / 耗时
- LLM 的每一步思考（assistant message）
- 子 agent（通过 delegate/explore 创建）以嵌套子树展示
- token 消耗实时累计
- 断开 WebSocket 重连会自动补历史事件

### A.6 结论

> **支持。** 框架自带 REST + WebSocket + React 前端三件套，可以实时观察 `auto_put_ad_mini` 10 步 pipeline 的每一步，包括子 agent 的嵌套执行与每次工具调用的完整入参出参。只要启动顺序正确（API 指向项目的 `.trace` 目录、前端 dev server、agent 执行），浏览器里能看到活动的目标树与消息流。
