落地并存测服全端:智能体 Graph/路由、后台切 8180、主账与部署脚本齐备,便于线上重发与验收。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-13 21:15:58 +08:00
co-authored by Cursor
parent 0eb1adecc8
commit 8103b3b651
143 changed files with 8968 additions and 277 deletions
+185 -35
View File
@@ -1,5 +1,5 @@
"""
任务槽与认领循环:识别、LibreOffice、Archive 等。
任务槽与认领循环:识别、LibreOffice、Archive、wake、LLM Stream。
本包约束(硬):LibreOffice 仅 1 槽;HTTP 类 2~3 槽;inbox/outbox 不得与 soffice 串同一阻塞线。
禁止:一把大锁串行所有工单;禁止 wecom:worker:singleton。
@@ -11,7 +11,18 @@ import logging
import threading
import time
from dataclasses import dataclass, field
from typing import Callable
from typing import Callable, Optional
from agent.config import Settings, get_settings
from agent.graph.runtime import GraphRuntime
from agent.jobs.archive import archive_stream, process_archive_stub
from agent.jobs.libreoffice import LibreOfficeConverter
from agent.jobs.pdf_job import pdf_stream, process_pdf_job
from agent.jobs.recognition import process_recognition_stub
from agent.jobs.wake_consumer import claim_and_apply_wake, wake_stream
from agent.llm import LlmGateway, LlmMode
from agent.redis_coord.runtime import RedisRuntime
from agent.routing import DeepSeekARouter
logger = logging.getLogger(__name__)
@@ -22,7 +33,6 @@ class SlotPool:
进程内有限槽线程池骨架。
用途:限制同类慢活并发(尤其 LibreOffice=1)。
为何不用进程级 Redis 单例:同机只应起一份 Worker 服务;防重入靠任务认领。
"""
name: str
@@ -35,11 +45,7 @@ class SlotPool:
self._sem = threading.BoundedSemaphore(self.size)
def run(self, fn: Callable[[], None], *, label: str = "") -> None:
"""
占用一个槽执行 fn;槽满则阻塞等待(调用方应在 Worker 线程,勿在 HTTP 回调线程调用)。
副作用:执行 fn;不写库(骨架)。
"""
"""占用一个槽执行 fn;勿在 HTTP 回调线程调用。"""
acquired = self._sem.acquire(timeout=30)
if not acquired:
logger.warning("槽超时未获取 name=%s label=%s", self.name, label)
@@ -53,20 +59,33 @@ class SlotPool:
@dataclass
class WorkerRuntime:
"""
Worker 运行时:持有分槽与 inbox/outbox 占位线程。
Worker 运行时:分槽 + wake / LLM / 识别 / 存档 / LibreOffice 循环。
启动时机:worker_app main;停止:进程退出或 stop()。
禁止:把所有任务丢进 LibreOffice 槽。
启动时机:worker_app main。
"""
libreoffice: SlotPool
http_jobs: SlotPool
settings: Settings
graph: Optional[GraphRuntime] = None
redis: Optional[RedisRuntime] = None
llm: Optional[LlmGateway] = None
_stop: threading.Event = field(default_factory=threading.Event)
_threads: list[threading.Thread] = field(default_factory=list)
@classmethod
def from_settings(cls, libreoffice_slots: int, http_job_slots: int) -> "WorkerRuntime":
"""按配置创建槽;强制 LibreOffice 槽为 1,防止误配双 soffice。"""
def from_settings(
cls,
libreoffice_slots: int,
http_job_slots: int,
*,
graph: Optional[GraphRuntime] = None,
redis: Optional[RedisRuntime] = None,
llm: Optional[LlmGateway] = None,
settings: Optional[Settings] = None,
) -> "WorkerRuntime":
"""按配置创建槽;强制 LibreOffice 槽为 1。"""
cfg = settings or get_settings()
lo = 1 if libreoffice_slots != 1 else libreoffice_slots
if libreoffice_slots != 1:
logger.warning(
@@ -76,47 +95,178 @@ class WorkerRuntime:
return cls(
libreoffice=SlotPool("libreoffice", lo),
http_jobs=SlotPool("http_jobs", max(2, http_job_slots)),
settings=cfg,
graph=graph,
redis=redis,
llm=llm,
)
def start(self) -> None:
"""
启动 inbox / outbox / 空转巡检线程(骨架:只心跳日志,不消费真实队列)。
副作用:创建守护线程;不连 Redis(骨架)。
"""
"""启动分槽消费循环。"""
self._stop.clear()
self._threads = [
threading.Thread(target=self._inbox_loop, name="inbox-loop", daemon=True),
threading.Thread(target=self._outbox_loop, name="outbox-loop", daemon=True),
threading.Thread(target=self._http_slot_idle, name="http-slots-idle", daemon=True),
threading.Thread(target=self._wake_loop, name="wake-loop", daemon=True),
threading.Thread(target=self._llm_loop, name="llm-loop", daemon=True),
threading.Thread(target=self._recognition_loop, name="recog-loop", daemon=True),
threading.Thread(target=self._archive_loop, name="archive-loop", daemon=True),
threading.Thread(target=self._pdf_loop, name="pdf-loop", daemon=True),
]
for t in self._threads:
t.start()
lo = LibreOfficeConverter.from_settings(self.settings)
logger.info(
"WorkerRuntime 已启动 libreoffice_slots=%s http_job_slots=%s",
"WorkerRuntime 已启动 libreoffice_slots=%s http_job_slots=%s soffice=%s",
self.libreoffice.size,
self.http_jobs.size,
"ok" if lo.available() else "missing",
)
def stop(self) -> None:
"""请求停止占位循环;不保证立刻打断正在跑的槽任务(骨架无真实任务)。"""
self._stop.set()
def _inbox_loop(self) -> None:
"""inbox 独立线程:后续从 PG/队列认领入站;禁止与 soffice 共用阻塞队列。"""
def _wake_loop(self) -> None:
"""HTTP 类槽消费结构化 wake → Graph。"""
if not self.redis or not self.graph:
while not self._stop.is_set():
time.sleep(2.0)
return
q = wake_stream(self.redis.client)
consumer = f"wake-{threading.get_ident()}"
while not self._stop.is_set():
time.sleep(2.0)
def _outbox_loop(self) -> None:
"""outbox 独立线程:后续发送企微;失败重试;过期丢弃不计业务成功。"""
def _job() -> None:
claim_and_apply_wake(queue=q, graph=self.graph, consumer=consumer)
self.http_jobs.run(_job, label="wake")
time.sleep(0.2)
def _llm_loop(self) -> None:
"""
HTTP 类槽消费 LLM Stream。
ordered_actions:走 DeepSeekARouter(默认 stub;LLM_ALLOW_NETWORK 才可能真网)。
其它 mode:骨架 stub。
"""
if not self.redis or not self.llm:
while not self._stop.is_set():
time.sleep(2.0)
return
stream = self.redis.llm_stream
stream.ensure_group()
consumer = f"llm-{threading.get_ident()}"
router = DeepSeekARouter.from_settings(
self.settings,
allow_network=bool(self.settings.llm_allow_network),
)
while not self._stop.is_set():
time.sleep(2.0)
def _http_slot_idle(self) -> None:
"""证明 HTTP 类槽可并行占用;骨架不跑真实 LLM。"""
def _job() -> None:
claimed = stream.claim(consumer=consumer, count=1, block_ms=200)
if not claimed:
return
entry_id, data = claimed[0]
mode = str(data.get("mode") or "ordered_actions")
payload = dict(data.get("payload") or {})
try:
if mode == LlmMode.ORDERED_ACTIONS.value:
decision = router.route(
text=str(payload.get("text") or ""),
sender_id=str(payload.get("sender_id") or ""),
)
logger.info(
"llm 槽 A 路由 id=%s intent=%s",
entry_id,
decision.intent,
)
else:
result = self.llm.invoke_stub(mode=mode, payload=payload)
logger.info(
"llm 槽处理 id=%s mode=%s stub=%s",
entry_id,
mode,
result.get("stub"),
)
finally:
stream.ack(entry_id)
self.http_jobs.run(_job, label="llm")
time.sleep(0.2)
def _recognition_loop(self) -> None:
"""HTTP 类槽消费识别 Stream。"""
if not self.redis:
while not self._stop.is_set():
time.sleep(2.0)
return
stream = self.redis.recognition_stream
stream.ensure_group()
consumer = f"recog-{threading.get_ident()}"
while not self._stop.is_set():
def _noop() -> None:
time.sleep(0.05)
self.http_jobs.run(_noop, label="idle-ping")
time.sleep(5.0)
def _job() -> None:
claimed = stream.claim(consumer=consumer, count=1, block_ms=200)
if not claimed:
return
entry_id, data = claimed[0]
try:
process_recognition_stub(data)
finally:
stream.ack(entry_id)
self.http_jobs.run(_job, label="recognition")
time.sleep(0.2)
def _archive_loop(self) -> None:
"""HTTP 类槽消费存档 Stream(与 soffice 隔离)。"""
if not self.redis:
while not self._stop.is_set():
time.sleep(2.0)
return
stream = archive_stream(self.redis)
stream.ensure_group()
consumer = f"arch-{threading.get_ident()}"
while not self._stop.is_set():
def _job() -> None:
claimed = stream.claim(consumer=consumer, count=1, block_ms=200)
if not claimed:
return
entry_id, data = claimed[0]
try:
process_archive_stub(data)
finally:
stream.ack(entry_id)
self.http_jobs.run(_job, label="archive")
time.sleep(0.2)
def _pdf_loop(self) -> None:
"""仅 LibreOffice 单槽消费 PDF Stream。"""
if not self.redis:
while not self._stop.is_set():
time.sleep(2.0)
return
stream = pdf_stream(self.redis)
stream.ensure_group()
converter = LibreOfficeConverter.from_settings(self.settings)
consumer = f"pdf-{threading.get_ident()}"
while not self._stop.is_set():
def _job() -> None:
claimed = stream.claim(consumer=consumer, count=1, block_ms=200)
if not claimed:
return
entry_id, data = claimed[0]
try:
result = process_pdf_job(data, converter=converter)
logger.info(
"pdf 槽 id=%s ok=%s err=%s",
entry_id,
result.ok,
result.error or "-",
)
finally:
stream.ack(entry_id)
self.libreoffice.run(_job, label="pdf")
time.sleep(0.3)