故障知识库
TROUBLESHOOTING
状态:🟢 现行 · 最后核对 2026-08-28
症状 → 根因 → 修复。每次修完 bug 加一条。Ctrl+Shift+F 搜症状关键词快速定位。
服务崩溃
服务循环重启(activating auto-restart, status=1)
症状:systemctl status rhodes 显示 activating (auto-restart),重启计数器持续增长
排查:
tail -30 /opt/rhodes-island/data/logs/error.log | grep -A5 "Traceback"
python -c "from app.main import app" # 直接测 import
常见根因:
| 根因 | 日志关键词 | 修复 |
|---|---|---|
| 权限问题 | PermissionError: .../data/logs/ |
chown -R root:root /opt/rhodes-island/data/ |
| 缺少 import | NameError: name 'Path' is not defined |
加 from pathlib import Path |
| 嵌入模型下载失败 | OSError: BAAI/bge-m3 + Network unreachable |
切回 MiniLM 或设 HF_ENDPOINT |
| .env 覆盖默认值 | 模型加载了 MiniLM 而不是 BGE | 检查 .env 里的 LOCAL_EMBEDDING_MODEL |
修复后验证:
sudo systemctl restart rhodes && sleep 15
sudo systemctl status rhodes | grep "Active:"
# 应该显示 active (running),不出现 restart counter
git pull 说「本地改动会被覆盖」(2026-08-05)
症状:
error: Your local changes to the following files would be overwritten by merge:
data/<某个运行时文件>.json
根因不是「谁改了那个文件」,是它一开始就不该被跟踪。
⚠️ .gitignore 里 data/ 不是整目录忽略的,是逐个文件列的 ——
所以往 data/ 下新加一个应用会写的文件时,默认就是被跟踪的,
而且不会有任何提示。git add -A 会把它连同里面的运行时数据一起提交。
分两种情况,修法完全不同:
| 这个文件在仓库里有用吗 | 修法 |
|---|---|
没用(纯运行时状态,如 data/key_health.json) |
加进 .gitignore + git rm --cached |
有用(提供初值,如 data/runtime_config.json) |
git update-index --skip-worktree <文件> |
⚠️ --skip-worktree 是只存在于本机 .git 里的设置,不在版本库中 ——
换台机器、重新 clone 就没有了。所以它是 deploy/setup.sh 的第 7 步,
不能靠人记得。查它有没有生效:
git ls-files -v data/runtime_config.json # 开头是 S 就对了
⚠️ 代价:仓库里对那个文件的合法更新也不会再应用到这台机器。 对「服务器运行时状态」这个取舍是对的,但改了仓库里那份之后 别指望它自己生效。
改了但没生效(2026-07-29/30 新增,这类占比最高)
症状:代码改对了、部署了、日志零报错,但功能就是没变化。
按这个顺序查,不要跳步(跳步的代价见下面每条的实例):
| # | 查什么 | 命令 | 踩过的实例 |
|---|---|---|---|
| 1 | 生产目录是哪个 | systemctl cat rhodes | grep WorkingDirectory |
服务器上同时有 ~/rhodes-island(停在 master 的旧 clone)和 /opt/rhodes-island,差点在错目录打完补丁宣布修好 |
| 2 | 代码真在里面吗 | 该目录 git log -1 + grep 关键函数 |
— |
| 3 | 服务重启过吗 | systemctl show rhodes -p ActiveEnterTimestamp --value |
— |
| 4 | 才轮到传输层(且先看配置再改代码) | 见下条 | 深聊不流式——原因至今未定,别照抄结论 |
深聊/聊天不是逐字出现(明明后端在流式)
症状:等十几秒,整段一次性蹦出来。后端逐 token yield、前端 getReader() 增量渲染,
两边代码都对,日志一个 ERROR 都没有。
根因:🔴 未定。2026-07-30 复核推翻了原先的结论,见下。
先按这个顺序查(顺序本身是重点):
- 服务重启过吗 ——
systemctl show rhodes -p ActiveEnterTimestamp --value。 07-29 那次最可能就是这一条:补丁先打在陈旧 clone~/rhodes-island上, 后来才打进真正在跑的/opt/rhodes-island并重启,于是分支里早就有的 流式改动才上线。 - 前端真的在增量渲染吗 ——
getReader()那个循环里加一行console.log, 看是逐块到还是一次到。这一步能把问题一刀切在前后端之间,最该先做。 - 才轮到传输层。而且要先看配置再改代码:
sudo nginx -T | grep -n "proxy_buffering\|gzip"。
现有措施:SSE 端点统一带 core.stream_filters.SSE_HEADERS
(含 X-Accel-Buffering: no)。保留是因为无害、且换代理/加 CDN 时真需要。
⚠️ 原先这里写的是「nginx 默认
proxy_buffering on把分块攒起来」,那是错的。 实测生产 nginx 反代 8001 的location /里本来就有proxy_buffering off;gzip on但gzip_types是注释状态,默认只压text/html,不含text/event-stream; 应用层也没有 GZipMiddleware。三个缓冲点全排除,那个头是 no-op。同时作废的还有一句旁证推理:「群聊回复短、填不满缓冲区,所以群聊流式正常是假象」—— 那是为了自圆其说而编的,缓冲区根本没参与。
教训:一个说得通的机制 + 一次成功的观察,不等于因果。 这和「验证手段选错比没验证更糟」是同一类错误的两个方向。
日志里查不到某条错误,但问题确实存在
根因:本项目的应用日志走 loguru 写 data/logs/rhodes_YYYY-MM-DD.log,不走 stdout。
journalctl -u rhodes 里永远看不到它们。
2026-07-30 因为这个得到过假阴性:怀疑对了元凶,用 journalctl -f | grep 验证得到"不跳",
据此把真元凶排除了,然后带着假前提绕了三轮。验证手段选错比没验证更糟。
⚠️ 反过来也要注意:pytest 和生产写同一个日志文件。日志里的错误可能是测试产生的
(test_op / test_operator 是测试干员名)。按时间戳和相邻行判断来源。
定位来源的通用招式
grep -n -B8 "<错误关键字>" data/logs/rhodes_$(date +%F).log | tail -30
错误的节律本身就是信息:
| 形状 | 指向 |
|---|---|
| 固定间隔(每 60s / 每 2h) | 后台任务(agent_loop / narrative.scheduler) |
紧跟某条 INFO(如 chat_endpoint:787) |
那个请求路径 |
| 只在启动附近、按字母序铺开 | 启动钩子遍历全部干员 |
用户数据串号 / 读到别人的数据
关系视图里的信赖数字不是自己的
根因:RelationGraph 是进程级全局单例,_doctor_edges 曾只用 op_id 做键——
所有用户共用一份。谁最后和某个干员聊过,谁的数字就是所有人看到的那份。
2026-07-30 已修(键改 {username}:{op_id})。
同类风险:任何进程级单例里存用户数据的字典,键必须含 username。
干员情绪状态 / 世界观上下文像是别人的
根因:request_username 是 ContextVar,worker 线程不继承它。
往 ThreadPoolExecutor 裸提交靠它定位用户的调用,会静默读写 _no_user_ 那份公共桶。
判定:日志里每次调用刷一条 request_username 为空,降级为 _no_user_。
修复:submit(contextvars.copy_context().run, fn, arg),
每个任务各自一份快照(共用一个 Context 并发进入会抛 RuntimeError: cannot enter context)。
data/users/ 下出现没人认识的"用户"
根因:_no_user_ / __no_user__ 是降级目录,但它们是目录,
裸 iterdir() 列用户会把它们当成真用户——心跳为它们跳动、主动消息为它们生成、L1 为它们累积。
一旦生成出 *_somatic.json 就自我喂养、永不停止。
修复:列用户只用 paths.list_real_users()。已攒的数据用
scripts/archive_pseudo_users.py 挪到 data/_orphans/。
前端问题
主题换了,但某块区域还是旧主题的颜色
先查那块地方是不是根本不吃 token。 2026-07-31 用户报「纸和铜锈最上面和最下面那块
是黑的」,根因不是某处写错值 —— 顶栏 / Tab 栏 / 弹窗这一族当时写死
rgba(30,30,30,.82),浅色化靠一串 [data-theme="light"] #tab-bar { … } 手写选择器,
加新浅色主题时没往里补名字。
# 主题块之外还有哪些颜色字面量
grep -n "rgba\?(\|#[0-9a-fA-F]\{6\}" frontend/css/style.css | grep -v "^\s*--"
再查是不是 JS 用内联样式盖掉了。 同一天还栽过一次:整套 --chat-ground token 做完、
「夕」也设了 none,聊天页照旧铺甲板照片 —— 因为 main.js 的 _applySceneBackground()
在 CSS 之外又用 view.style.backgroundImage 写了一遍,内联优先级高于任何 CSS 规则。
当时只在 CSS 里搜了 backgrounds/,没搜 JS。
grep -rn "\.style\.background\|\.style\.color" frontend/js/*.js
第三种:主题块漏了某个 token → 静默落回 :root 的深色值。跑
pytest tests/test_theme_polarity.py 就能查出来。
批量改完 JS,语法检查过了但行为不对
node --check 挡不住注释被破坏。 2026-07-31 的批量脚本往一行 // 注释中间塞了
多行内容,// 只管到第一行,后面几行变成了真代码:
// …但也不能像原来那样 .catch(() => {
// apiFetch 内部已经…
}) 全吞:
语法检查照样通过(残骸恰好仍是合法 JS)。可靠的查法是剥掉注释只比对真代码行:
# 剥注释后 diff 改动前后,任何"注释变代码"都会显形
# 完整实现见 tests/test_no_silent_catch.py 里的 _comment_mask
根因是判断「某处是不是注释」,正则和朴素扫描都不够。同一天栽了两次:
avatarFileInput.accept = 'image/*' 里的 /* 被当成块注释开头(那行之后所有代码被判成
注释,9 处真代码被静默跳过);.replace(/[&<>"']/g, …) 这个正则字面量里的引号被当成
字符串开合(后面的注释不再被识别)。要可靠区分,扫描器必须同时处理字符串、模板串
和正则字面量。
改了前端,自己刷新看得到,别人看不到
按这个顺序查:
- 跑过
python scripts/bump_frontend_cache.py没有? 相对 import 不继承父模块 URL 上的查询串 ——main.js?v=xxx管不到./settings.js - nginx 有没有覆盖 Cache-Control?
两边不一样就是 nginx 在改。curl -sI https://manana.icu/js/main.js | grep -i cache-control curl -sI http://127.0.0.1:8001/js/main.js | grep -i cache-control # 源站/js//css/应该是no-cache, must-revalidate - 那个文件是 nginx 直接发的还是代理出去的?
/js//css/用的是alias(从磁盘直发,不经过 FastAPI),所以app/main.py的中间件对它们无效
页面白屏
症状:刷新后完全空白,Console 有红色 JS 错误
常见根因:
| 根因 | 修复 |
|---|---|
operatorConfig[opId] 为 undefined |
加 ` |
| IndexedDB 读取失败 | 清浏览器缓存 → 硬刷新 |
| 旧 JS 缓存 | Ctrl+Shift+R 硬刷新,或改 main.js?v=0102 版本号 |
AI 回复只显示 "...."
症状:气泡内容只有 3-4 个点
排查流程:
- 查服务日志
grep "LLM chat" data/logs/ | tail -5——看回复 token 数 - 如果 1536tk → 不是后端问题,是前端 SSE 中断
- 如果 1-2tk → 后端真的回了省略号
常见根因:
| 根因 | 修复 |
|---|---|
| 限流 429 被当 SSE 解析 | 前端加 429 处理 + toast 提示。见 CHANGELOG 2026-06-26 条目 |
| 移动端 fetch 断连 | 深聊长回复 8s SSE 流被移动网络截断。加重试按钮(未修,技术债务 #5) |
[继续] 指令触发 |
深聊空输入自动发 [继续]——LLM 可能输出 …… |
聊过的干员点进去就重加载
症状:有聊天记录的干员一点就刷新页面,没聊过的正常
根因:operatorConfig[opId] 为 undefined——共享源 operator-config.js 和 main.js 内联回退不一致
修复:所有 operatorConfig[id] 访问加 || {} 兜底。见 668e748
记忆与检索
Embedding 维度不匹配
症状:
ValueError: shapes (3,384) and (512,) not aligned
WARNING: L4 检索失败: Collection expecting embedding with dimension of 384, got 1024
根因:切换嵌入模型后,旧数据(SQLite/ChromaDB)还是旧维度
修复:
| 位置 | 修复 |
|---|---|
| deep_chat detail_index | 过滤 len(emb) != len(query) 的旧条目 |
| L4 ChromaDB | catch dimension error → 静默跳过 |
| L3 旧集合 | 双集合过渡——新事实写 BGE,旧集合只读降权。查询时 catch 维度错误 |
预防:切换嵌入模型前先备份 ChromaDB 目录
启动报 KeyError: '_type'(chromadb 版本与数据格式不匹配)
症状:python -c "from app.main import app" 崩在 l4_world.py 的模块级单例上:
File "chromadb/api/configuration.py", line 209, in from_json
f"...from JSON with type {json_map['_type']}"
KeyError: '_type'
根因:装的 chromadb 版本和写数据的版本对不上。config_json_str 为 '{}' 时
0.5.x 的解析器会 KeyError,而 1.x 对默认配置写的正是 '{}'(真配置在 schema_str)。
⚠️ 先判断方向:是该升还是该降?
看 SQLite 的 schema,不要靠猜:
python3 -c "
import sqlite3
c=sqlite3.connect('file:data/users/Doctor/chroma_l3/chroma.sqlite3?mode=ro',uri=True)
print('列 :', [r[1] for r in c.execute('PRAGMA table_info(collections)')])
print('迁移:', [r[0] for r in c.execute(\"SELECT filename FROM migrations WHERE dir='sysdb' ORDER BY filename\")])
c.close()"
| 看到什么 | 说明 | 怎么办 |
|---|---|---|
有 schema_str 列 / sysdb 迁移到 00010-collection-schema |
数据由 1.x 写的 | 升级 pip install --upgrade "chromadb>=1.0" |
只有 config_json_str、无 schema_str |
数据由 0.5.x 或更早写的 | 装对应的旧版 |
判断依据:数据库里如果存在当前版本不认识的列或迁移记录,说明写它的版本更新 —— 必须往上走。
2026-07-29 的真实案例:生产环境跑的是 1.x,requirements.txt 被改成
chromadb>=0.4.22,<0.6.0 后 pip 静默降级到 0.5.23,17 个 collection 全部读不出来。
当时第一反应是"数据是老格式,该降级"——方向正好反了,靠 schema_str 这一列才纠正过来。
数据没坏:报错发生在读配置阶段,向量本身完好。别删库。
预防:requirements.txt 里 chromadb 必须精确钉死(==X.Y.Z),
改这一行之前先在服务器上 pip show chromadb 拿到实际版本。
ChromaDB 写入静默失败
症状:健康仪表盘 BGE 集合计数不增长,但无 ERROR 日志
排查:
python -c "
from app.systems.memory.l3_long import l3_long_memory
print(f'BGE: {l3_long_memory.bge_collection.count()} 条')
print(f'旧: {l3_long_memory.collection.count()} 条')
"
根因:l3_long.py 多处 except: pass 静默吞错——写入失败完全不可见
修复:在 add_facts/add_wiki_fact 的 except 块加 logger.warning。见 tech-debt.md M3
性能问题
用户感知延迟 > 20 秒
排查:
grep "LLM chat" data/logs/ | tail -10 # LLM 实际耗时
grep "embed" data/logs/ | tail -5 # Embedding 耗时
常见根因:
| 根因 | 表现 | 修复 |
|---|---|---|
| BGE-M3 在 CPU 上 | 单次 embed 3-5s,每轮 3 次→12s | 切 BGE-small(9ms)或 FP16 |
| 本地 qwen 内存满 | Swap 100%,所有请求卡 | 禁用 qwen,后端走 deepseek |
| LLM API key 失效 | 401 error,用户请求失败 | 换 key |
Swap 打到 100%
症状:free -m 显示 swap 满,服务响应极慢
根因:qwen2.5-1.5b 在 3.7GB 内存上跑不了
修复:systemctl stop llama-server && systemctl disable llama-server
预防:cron 每 5 分钟检查 swap,超 80% 告警。见审计报告基础设施部分
⚠️ 查日志之前先看这一节(2026-08-05 一天内栽了三次)
① journalctl 看不到应用日志
deploy/rhodes.service 里写着:
StandardOutput=append:/opt/rhodes-island/data/logs/server.log
StandardError=append:/opt/rhodes-island/data/logs/error.log
应用的全部输出都被重定向进文件了,journald 一个字都拿不到。
journalctl -u rhodes 只有 systemd 自己的事件(启动、停止、SIGKILL)。
于是 journalctl -u rhodes | grep 某个应用日志 恒为空 ——
而那看起来完全像「没有这个错误」。别拿它的空当结论。
查应用日志一律用 data/logs/rhodes_$(date +%F).log。
② 有两个长得像 error.log 的文件,来源完全不同
| 文件 | 谁写的 | 内容 |
|---|---|---|
data/logs/error.log |
systemd 重定向的 stderr | loguru 控制台 sink 的全部输出(带 ANSI 颜色码) |
data/logs/error_<日期>.log |
loguru 自己的 ERROR 级 sink | 只有 ERROR 及以上 |
挑错一个就会得出「什么都没有」。
③ ⚠️ rhodes_<日期>.log 只是当前那个分片
日志是 rotation="10 MB" + compression="zip":写满就轮转成
rhodes_<日期>.<时间戳>.log.zip,当前那个文件里只剩最近一小段。
一天的绝大部分历史在 .zip 里,grep 直接搜不到。
这条骗过的不只是人:scripts/llm_health.py 曾因此把「一天里的一小片」
报成全天统计 —— 分子分母都对,只是样本悄悄变成了尾部一段,
而它看起来是全量(已修,2026-08-05)。
搜全天要带上轮转的:
cd /opt/rhodes-island
# 未压缩的分片
grep -h "你要找的" data/logs/rhodes_$(date +%F)*.log 2>/dev/null
# 连 .zip 一起(zgrep 对 zip 不管用,得先解)
for z in data/logs/rhodes_$(date +%F)*.zip; do
[ -e "$z" ] && unzip -p "$z" | grep "你要找的"
done
常见命令速查
# 服务状态
systemctl status rhodes | grep -E "Active:|Main PID|counter:"
# 最近错误
grep "ERROR" data/logs/rhodes_$(date +%Y-%m-%d).log | tail -20
# LLM 延迟采样
grep "LLM chat" data/logs/rhodes_$(date +%Y-%m-%d).log | tail -10
# 内存
free -m && grep "swap\|mem" data/logs/monitor.log 2>/dev/null | tail -5
# 磁盘
df -h /opt
# 健康仪表盘
curl -s http://localhost:8001/api/memory/health/dashboard | python3 -m json.tool 2>/dev/null | grep -E "embedding_health|avg_latency|error_rate|recall|bge_facts"
Windows 上压缩 zip 别用 tar -a(2026-08-28)
症状:tar -a -c -f x.zip a.txt b.txt 生成的 .zip 双击无法解压、
资源管理器报「文件为空/损坏」;python -c "zipfile.ZipFile(...)" 报 BadZipFile。
根因:git bash 的 tar 是 GNU tar,它的 -a(--auto-compress)只认
gzip/bzip2/xz 等压缩格式,不认 .zip 扩展名——于是退化成普通 tar 归档,
只是文件名骗人叫 .zip。用 file x.zip 一看就知道:显示
POSIX tar archive (GNU) 而不是 Zip archive data。
修复:Windows 上生成 zip 用下面任一种,别用 tar -a:
- Python(中文文件名最稳,自动 UTF-8 flag):
zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED)+z.write(src, arcname=name) - PowerShell:
Compress-Archive -Path ... -DestinationPath x.zip
相关文档:CHANGELOG · tech-debt · smoke-test
(原先链接的
engineering-log和architecture-audit-2026-06-27两份文档已不存在—— 前者已并入 CHANGELOG.md,后者已并入 tech-debt.md。)