# xxm（小小莫）稳定性与观察者模式

> 最后更新：2026-06-03

---

## 架构概要

xxm（`xxm@yoin.fun`）是一个 XMPP bot，通过 slixmpp 连接 ejabberd（`xmpp.yoin.fun:3021`），作为"小小莫"在群聊 `coregroup@conference.yoin.fun` 中观察和响应消息。

```
XMPP 消息
  ↓
xmpp_bot.py ← on_message / on_group_message
  ↓
session_router.py → route() → 构建 prompt
  ↓
chat_bridge.py → send_raw() → HTTP API 调用
  ↓
volcengine API (deepseek-v4-flash)
```

---

## 2026-06-03 修复记录

### 一、HTTP 请求死锁（最严重的稳定性问题）

**问题**：`urllib.request.urlopen(req, timeout=timeout)` 在 Windows 特定网络条件下，timeout 参数不触发，请求永远挂在 socket 上。桥接线程卡死 → bot 不回消息。

**修复**：弃用 `urllib.request`，改用 `requests` 库。

```python
# 旧（会挂死）
req = urllib.request.Request(url, data=data, headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as resp:
    body = json.loads(resp.read())

# 新（Connect=10s, Read=60s，分别起效）
resp = requests.post(url, json=payload, timeout=(10, timeout))
resp.raise_for_status()
body = resp.json()
```

**效果**：
- Connect timeout 10s：DNS / 连接层挂死不再发生
- Read timeout 60s：API 响应慢也能兜底
- 异常精确分类：`Timeout` / `HTTPError` / `RequestException` 分别打不同日志

**涉及文件**：`chat_bridge.py`

---

### 二、工具循环截断

**问题**：`_MAX_TOOL_LOOPS = 5`，LLM 做多步排查（SSH 到服务器逐条查 nginx 配置）时很容易耗尽，耗尽后 `return None` → bot 告诉用户"模型无响应"。

**修复**：
1. `_MAX_TOOL_LOOPS = 5 → 50`（足够做深度排查）
2. 50 轮耗尽后，再调一次**不带 tools 参数**的 API，强制模型用文字总结

```python
# 耗尽后的兜底
final_resp = session.post(final_url, json={"model": model, "messages": messages}, ...)
final_msg = final_resp.json()["choices"][0]["message"]["content"]
if final_msg.strip():
    return final_msg.strip()
```

**涉及文件**：`chat_bridge.py`

---

### 三、API Key 冷却与 retry-cache 绕过

**问题**：opencode-go / opencode-go-new 两个 key 都在 403 冷却期（`code 1010`）。同时 opencode 的 retry-cache 机制（`#25803/#24462`）按 `(provider, model)` 缓存 429 错误，导致切换到另一个 provider 后仍然被旧 cache 截断。

**修复**：
1. 创建 `api_proxy.py` 本地 HTTP 代理（`:8787`）
2. 模型名重映射：`deepseek-v4-flash-safe` → `deepseek-v4-flash`（不同的模型名绕开 retry-cache key 碰撞）
3. 代理吞掉 429/5xx 错误码，自动重试最多 3 次（指数退避 1s/2s/4s）
4. 代理配置为独立 provider：`volcengine-proxy/deepseek-v4-flash-safe`

```json
{
  "volcengine-proxy": {
    "type": "openai",
    "options": {
      "baseURL": "http://localhost:8787",
      "apiKey": "...",
      "model": "deepseek-v4-flash-safe"
    }
  }
}
```

**涉及文件**：`api_proxy.py`、`start_proxy.bat`、`~/.config/opencode/config.json`

---

### 四、观察者模式与 `__SILENT__` 协议

**问题**：xxm 在群里应该只回应 @自己的消息，其他消息保持沉默。但 LLM 会说"我应该保持沉默"然后把这句话本身发出去（没有用 `__SILENT__` 前缀）。

**修复**（两层防护）：

**第一层 — System Prompt 明确教学**（`chat_bridge.py`）：
```
=== 群聊沉默协议 ===
保持沉默的方法：在回复的最开头写 __SILENT__
系统检测到 __SILENT__ 就不会把消息发出去。
注意：不要直接把"我应该保持沉默"当回复发出去。
```

**第二层 — 自然语言兜底**（`xmpp_bot.py`）：
```python
_SILENCE_PATTERNS = [
    "保持沉默", "不应[该]?回复", "没有.*@.*我",
    "不是对[我我说]", "跟我无关", "我不用回复",
]
```
如果回复第一行匹配任何静默模式（即便没有 `__SILENT__` 前缀），直接 suppress。只检查第一行，避免误杀多行正常回复。

**涉及文件**：`chat_bridge.py`、`xmpp_bot.py`

---

### 五、群消息合并与串行化

**问题**：同个群多条消息靠近到达时，每条都触发独立的 LLM 调用和工具循环，导致：
- 并发工具调用互相干扰（同时 SSH 到同一台服务器）
- 多条重复排查（浪费额度）
- log 混乱

**修复**：3 秒 debounce + 房间级串行化

**三个状态 / 两个路径**：

```
消息到达
  ├─ @xxm → 立即处理（绕过 batch）
  └─ 其他 → 进入 batch 系统
               │
         [BATCHING] 3s 窗口内可合并
               │  Timer 到期
               ▼
         [PROCESSING] LLM 调用中
               │  新消息 → 进入 pending 队列
               │  LLM 结束
               ▼
         [_batch_done] 检查 pending
               ├─ 有 pending → 立即发起下一批
               └─ 无 pending → IDLE
```

**保证**：同一房间同一时刻最多一个 LLM 调用在处理。

**涉及文件**：`xmpp_bot.py`

---

### 六、context log 保存（self-message）

**问题**：LLM 看不到自己在群里说过什么，因为 bot 的 self-message 被直接丢弃了。每次都是"失忆"状态。

**修复**：self-message 不走 LLM，但写入 bridge context log（`_append_to_log("assistant", body)`），这样下次 LLM 调用时通过 `_read_recent_context()` 能看到自己说过的话。

**涉及文件**：`xmpp_bot.py`（`on_group_message` 中 `nickname == bot_nick` 分支）

---

### 七、`part_` → `prt_` 前缀修复（2026-06-11）

**问题**：`chat_bridge.py` 的 `_append_to_session()` 生成 part ID 时用了 `part_` 前缀：
```python
part_id = "part_" + _uuid.uuid4().hex[:24]  # BUG!
```
但 OpenCode 1.17+ 要求 part ID 必须用 `prt_` 前缀。这导致：
- 每次 xxm 写入消息到 session → 产生 `part_` 数据
- compaction 扫描到旧前缀 → schema 校验失败 → 死循环
- session 崩掉 → xxm 也卡住

**修复**：`part_` → `prt_`（`chat_bridge.py:335`）

**连带发现**：`ses_xxm_xmpp` session 在代码里配了但 DB 里不存在，已手动创建。

### 八、`session_search` 工具说明

**用途**：让 xxm 能搜索其他 session 的历史对话。已内置为 function calling tool。

**定义位置**：`chat_bridge.py` 的 `_TOOLS` 列表

| 参数 | 默认值 | 说明 |
|------|--------|------|
| `session_id` | 当前 session | 指定要搜索的 session ID，空字符串则查自己 |
| `limit` | 20（最大100） | 返回最近多少条消息 |

**调用方式**：LLM 通过 function calling 调用 `session_search`，不需要 xxm 写代码。

**注意**：archived session 仍可搜索（`extract_session_context` 直接从 message 表读，不依赖 session 状态）。

---

## 关键配置

| 参数 | 值 | 说明 |
|------|-----|------|
| `_MAX_TOOL_LOOPS` | 30 | 工具循环上限（超限后 clean final force，不泄漏 XML） |
| `DEFAULT_TIMEOUT` | 60s | 每次 API 调用 read timeout |
| `LOCK_DURATION` | 300s | 锁定成功 provider（避免频繁切换） |
| `FAILED_BACKOFF` | 1800s | 失败 provider 冷却 |
| `_BATCH_WINDOW` | 3.0s | 群消息合并窗口 |
| api_proxy 重试 | 3 次 | 指数退避 1s/2s/4s |

## Provider 优先级

```
volcengine (deepseek-v4-flash)        # 主用，火山免费额度
  → opencode-go-new (deepseek-v4-flash)  # 备用，有订阅但冷却中
  → opencode-go (deepseek-v4-flash)      # 备用，冷却中
```

## 日志

| 日志文件 | 路径 | 用途 |
|---------|------|------|
| `logs/xmpp_bot.log` | `projects/.../logs/` | XMPP 连接、消息收发、batch 状态 |
| `logs/bridge.log` | `projects/.../logs/` | LLM API 调用、耗时、工具调用 |
| `logs/api_proxy.log` | `projects/.../logs/` | 代理请求、错误吞没、重试 |
