Skip to content

fix: LLM 任务硬超时 + 消息落库异步化,避免主循环饥饿#1707

Merged
SengokuCola merged 14 commits into
Mai-with-u:devfrom
XXXxx7258:dev
May 17, 2026
Merged

fix: LLM 任务硬超时 + 消息落库异步化,避免主循环饥饿#1707
SengokuCola merged 14 commits into
Mai-with-u:devfrom
XXXxx7258:dev

Conversation

@XXXxx7258
Copy link
Copy Markdown
Contributor

@XXXxx7258 XXXxx7258 commented May 16, 2026

    • main 分支 禁止修改,请确认本次提交的分支 不是 main 分支
    • 我确认我阅读了贡献指南
    • 本次更新类型为:BUG修复
    • 本次更新类型为:功能新增
    • 本次更新是否经过测试
    • 如果本次修改涉及 src/A_memorix,我确认已阅读 src/A_memorix/MODIFICATION_POLICY.md,不涉及则无需勾选
  1. 请填写破坏性更新的具体内容(如有):
  2. 请简要说明本次更新的内容和目的:修复主进程连锁故障导致 NapCat 永久离线

其他信息

  • 关联 Issue:Close #
  • 截图/GIF
  • 附加信息:

Summary by CodeRabbit

发布说明

  • 新功能

    • 新增任务级硬超时配置,超时会触发任务级中断并尝试切换到下一个模型
    • 新增任务模板与默认模板中的硬超时字段
  • 改进

    • 将多处消息/回填/发送的数据库写入改为异步并等待完成,提升响应性
    • 引入进程级写入序列化以降低并发写入冲突风险
    • 优化模型请求的超时与取消清理逻辑,避免后台残留请求

Review Change Stack

XXXxx7258 added 13 commits May 16, 2026 20:39
- TaskConfig 新增 hard_timeout 字段(默认 180s);LLMOrchestrator 5 个公开入口统一套 asyncio.wait_for,超时抛 LLMTaskTimeoutError(继承 ModelAttemptFailed)复用切模型链路
- DEFAULT_TASK_CONFIG_TEMPLATES 9 个任务块种子化 hard_timeout:planner=120、utils/learner/emoji/voice=60、embedding=30、其余 180
- MessageUtils 新增 store_message_to_db_async(asyncio.to_thread 包装);message_gateway / heartflow_message_processor / bot / send_service 4 个调用点改为 await,消除主事件循环里同步 SQLAlchemy session 的阻塞

修复 LLM 单次调用被上游代理 TPM 静默排队卡死、叠加同步落库阻塞导致主循环饥饿、plugin.health 30s 超时连锁杀 napcat 的故障
PR review 反馈两处问题修正:

- hard_timeout 原先包在 _execute_request 整体外面,超时会取消整个 for 循环导致内部 except ModelAttemptFailed 进不去,"切下一个模型" fallback 失效且 usage_penalty -1 清理被绕过造成负载均衡计数器泄露。改为在 _execute_request 循环内对单次 _attempt_request_on_model 套 asyncio.wait_for,TimeoutError 转 LLMTaskTimeoutError(继承 ModelAttemptFailed)由现有 except 分支自动接住,复用切模型链路与计数器清理
- DEFAULT_TASK_CONFIG_TEMPLATES 新增的 5 个最小条目(learner/emoji/vlm/voice/embedding)只写了 hard_timeout,但 build_default_model_templates 用 task_template["model_list"] 直接索引,会让 create_default_model_config 在新部署时 KeyError。补 model_list=[]
PR review 第二轮反馈两处问题修正:

- build_single_model_task / summary_importer 重建 TaskConfig 时漏传 hard_timeout,导致 A_Memorix 单模型路由从 planner 模板派生时 120s 被静默退回 180s 默认值;同步带上字段
- _build_task_config_signature 未把 hard_timeout 纳入签名,热重载只改超时时签名不变会被误判为旧配置;签名元组追加 hard_timeout
- _attempt_request_on_model_with_timeout 内层 warning 与 _execute_request 的 except ModelAttemptFailed 分支日志重复(LLMTaskTimeoutError.message 已含 task/model/timeout 信息),删除内层日志
PR review 第三轮反馈:to_thread 默认线程池有多个 worker,突发消息流量下多个 worker
会并发拿 SQLAlchemy session 写入;底层 SQLite WAL 仅允许单写、busy_timeout 1s
不足以缓冲所有突发,会触发 `database is locked`。

新增 module-level `_DB_WRITE_LOCK = asyncio.Lock()`,store_message_to_db_async 在
持有 lock 后再 to_thread,把写入串行化;仍保持同步 SQLAlchemy session 在线程池里
执行,事件循环不阻塞。
PR review 第四轮反馈两处问题修正:

- await_task_with_interrupt 只在 interrupt_flag set 时 cancel child task;外层
  asyncio.wait_for 触发 hard_timeout 时仅 CancelledError 从 sleep 抛出,child task
  不被取消导致上游 httpx 请求继续在后台跑、占用连接 / token。加 try/finally 在所有
  退出路径上 cancel 未完成的 child task
- LLMTaskTimeoutError 构造里写死 original_exception=None 丢失了原始 asyncio.TimeoutError
  上下文。__init__ 接收 original_exception 并向 ModelAttemptFailed 透传;调用点传 e
PR review 第五轮反馈两处问题修正:

- _execute_request 内 `last_exception = e.original_exception or e` 会展开 original_exception,
  上一轮加的 `original_exception=e` 让所有模型都超时时上层最终收到 raw asyncio.TimeoutError
  而非承诺的 LLMTaskTimeoutError。撤回 original_exception 传递,原始 TimeoutError 仍通过
  `raise ... from e` 的 __cause__ 链保留供 traceback / 调试使用
- update_message_id 是同步 DB 写入,在 echo_message_process(async)里被裸调,违反本次
  消息落库异步化目标。新增 update_message_id_async 走同一把 _DB_WRITE_LOCK + to_thread;
  bot.py:473 调用点改为 await
PR review 第六轮反馈:module-level `asyncio.Lock()` 在测试场景(pytest-asyncio
每用例独立 loop)会绑定到第一个使用它的 loop,后续切换 loop 时跨 loop 复用。
改为 `_get_db_write_lock()` 懒初始化,首次需要时再构造,避免 import 期触发;
也与项目内其他 lock 全为函数/实例级的风格保持一致。
PR review 第七轮反馈:finally 里只 cancel 不 await,子任务在后台延迟清理,
若 cancel 后子任务清理过程中再抛异常会变成「Task exception was never retrieved」
警告污染日志。cancel 后用 contextlib.suppress 抑制 CancelledError 与清理异常
再 await,让子任务生命周期完全收敛在本函数内。
PR review 第八轮反馈两处问题合并修正:

- gemini 建议改用 functools.lru_cache(maxsize=None) 简化 lazy init,比手写
  `if None then create` 更 Pythonic
- sourcery 指出手写 lazy init 存在理论 race(两个协程同时见 None 各自建 Lock),
  lru_cache 内部用 thread lock 自动消除该 race
- 多 loop 场景下 Lock 仍绑定首次使用所在 loop(lru_cache 也一样),但 MaiBot
  单进程单 event loop 不会触发;在 docstring 显式注明该假设供后续维护参考
PR review 第九轮反馈实事求是修正:之前我注释里写「假设 MaiBot 是单进程单 event loop」
但实际上 bot.py 主 loop、WebUI 在另一个线程的独立 loop、asyncio.run 临时 loop 等
都同时存在;asyncio.Lock 只能互斥同一个 loop 内的协程,对跨 loop / 跨线程并发写
完全形同虚设。

改成 module-level threading.Lock:
- 在 to_thread worker 线程内同步持有,不依赖 event loop
- 进程级互斥,任何 loop / 任何线程发起的 SQLite 写都被串行
- 不再需要 lru_cache / lazy init 兜底跨 loop 风险,代码更直白
PR review 第十轮反馈两处问题修正:

- _DB_WRITE_THREAD_LOCK 之前只在 async wrapper 里持有,sync store_message_to_db /
  update_message_id 的直接调用方(如 pytests/test_message_image_persistence.py、
  迁移脚本)会绕过锁形成竞争。把 with _DB_WRITE_THREAD_LOCK 移入两个 sync 方法
  本体,所有写入路径共享同一套串行化;async wrapper 简化为 to_thread 直透传
- await_task_with_interrupt finally 里的 contextlib.suppress(Exception) 太宽,
  会静默掉子任务清理代码里的 AttributeError / TypeError 等编程错误。改成
  try/except CancelledError + Exception (debug 日志),CancelledError 静默,
  其他异常以 debug 记录避免完全淹没。同步移除文件顶部不再需要的 contextlib 导入
…db-store

fix: LLM任务级 hard_timeout 与消息落库异步化
Copilot AI review requested due to automatic review settings May 16, 2026 15:16
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 16, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fccc111c-f52b-4c5d-b560-a0d8f68b498d

📥 Commits

Reviewing files that changed from the base of the PR and between cec6aed and c8c13a6.

📒 Files selected for processing (1)
  • src/A_memorix/core/utils/summary_importer.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/A_memorix/core/utils/summary_importer.py

Walkthrough

PR 在多个层面引入任务级 hard_timeout(配置、模板、异常、orchestrator 包装与路由传播),并将消息写入路径改为受进程级锁保护的同步实现加 asyncio.to_thread 的异步透传,同时把调用方切换为 await 异步接口。

Changes

Task-Level Hard Timeout Control System

Layer / File(s) Summary
Task Configuration & Default Templates
src/config/model_configs.py, src/config/default_model_config.py
TaskConfig 新增 hard_timeout 字段(默认 180s,最小值 1.0),并在默认任务模板中补齐/新增各任务的 hard_timeout
Timeout Exception & Task Cleanup
src/llm_models/exceptions.py, src/llm_models/model_client/adapter_base.py
新增 LLMTaskTimeoutError 类;await_task_with_interrupt 在取消路径增加对未完成子任务的取消与 await 清理以避免后台泄露。
Orchestrator Timeout Wrapping & Model Fallback
src/llm_models/utils_model.py
新增 _attempt_request_on_model_with_timeout,用 asyncio.wait_for 对单次模型尝试施加 model_for_task.hard_timeout,超时转为 LLMTaskTimeoutError 由现有回退逻辑处理;签名构建加入 hard_timeout,并微调 413 重试判断写法。
Model Routing & Hard Timeout Propagation
src/A_memorix/core/utils/model_routing.py, src/A_memorix/core/utils/summary_importer.py
在构建单模型任务与 SummaryImporter 的 TaskConfig 时传入 hard_timeout;局部重写 model_list 映射生成与日志格式,逻辑等价。

Async Message Storage Serialization

Layer / File(s) Summary
DB Write Serialization Infrastructure
src/common/utils/utils_message.py
引入 threading.Lock(_DB_WRITE_THREAD_LOCK)与 asyncio 导入,用于进程级序列化所有 SQLite 写入。
Message Storage Methods with Lock Protection
src/common/utils/utils_message.py
在持锁下执行同步写入实现(store_message_to_db、update_message_id);提供 async 版本通过 asyncio.to_thread 调用同步实现以供事件循环中 await。
Async Call Sites & Formatting Adjustments
src/chat/heart_flow/heartflow_message_processor.py, src/chat/message_receive/bot.py, src/plugin_runtime/host/message_gateway.py, src/services/send_service.py
将消息存储/回填调用切换为 await 异步版本;send_service.py 同时移除 BOM 并做若干格式/日志重排。

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • Mai-with-u/MaiBot#1670: 与本 PR 在 model_routing.py 的 hard_timeout 织入与模型路由改动直接相关。
  • Mai-with-u/MaiBot#1709: 与 summary_importer.py 中 TaskConfig 构建与模型选择逻辑的修改存在交叉。
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed 标题清晰准确地总结了PR的两个主要变更:引入LLM任务硬超时机制和消息落库异步化,直接对应了代码变更的核心目的。
Description check ✅ Passed 描述按照模板结构完整填写了所有必需项:确认分支非main、阅读贡献指南、更新类型为BUG修复、已测试、已阅读MODIFICATION_POLICY.md,并在第7项说明了更新内容和目的。
Docstring Coverage ✅ Passed Docstring coverage is 84.62% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/common/utils/utils_message.py (1)

14-15: ⚡ Quick win

请按项目规范重排导入顺序与分组。

新增的标准库导入应与其他标准库导入放在同一分组,并整体保持“标准库/第三方/本地模块”分块顺序,避免后续 Ruff 导入规则不一致。

As per coding guidelines, **/*.py: "Place standard library and third-party library imports before local module imports" and "Separate import blocks with a single blank line".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/common/utils/utils_message.py` around lines 14 - 15, 调整
src/common/utils/utils_message.py 的导入顺序与分组:将新加的标准库导入 asyncio 和 threading
与文件中其它标准库导入放在同一“标准库”块,确保 import 块遵循“标准库 / 第三方 / 本地模块”顺序,并在各块之间保留一个空行以符合项目规范和
Ruff 规则;查找文件中涉及 asyncio 或 threading 的引用确保不破坏相对导入或本地模块顺序(例如与同文件或同包内的本地模块导入保持分隔)。
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/common/utils/utils_message.py`:
- Around line 14-15: 调整 src/common/utils/utils_message.py 的导入顺序与分组:将新加的标准库导入
asyncio 和 threading 与文件中其它标准库导入放在同一“标准库”块,确保 import 块遵循“标准库 / 第三方 /
本地模块”顺序,并在各块之间保留一个空行以符合项目规范和 Ruff 规则;查找文件中涉及 asyncio 或 threading
的引用确保不破坏相对导入或本地模块顺序(例如与同文件或同包内的本地模块导入保持分隔)。

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 138bcb46-f3e4-4bbb-9a47-5d009463e729

📥 Commits

Reviewing files that changed from the base of the PR and between 6ad68c1 and cec6aed.

📒 Files selected for processing (12)
  • src/A_memorix/core/utils/model_routing.py
  • src/A_memorix/core/utils/summary_importer.py
  • src/chat/heart_flow/heartflow_message_processor.py
  • src/chat/message_receive/bot.py
  • src/common/utils/utils_message.py
  • src/config/default_model_config.py
  • src/config/model_configs.py
  • src/llm_models/exceptions.py
  • src/llm_models/model_client/adapter_base.py
  • src/llm_models/utils_model.py
  • src/plugin_runtime/host/message_gateway.py
  • src/services/send_service.py

@SengokuCola SengokuCola merged commit aad5ea8 into Mai-with-u:dev May 17, 2026
3 of 4 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request May 18, 2026
6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants