runner.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. """定时任务执行器。
  2. 一次执行 = 建一个独立会话 + 跑一轮 agent + 把结果落库并计费。
  3. 为什么用「独立会话」而不是 1.5 那种 ``scheduled-task-{id}-{run_id}`` 裸线程 ID:
  4. 本系统其他所有地方都强制 ``thread_id ≡ session_id``(那条不变式是防止
  5. 跨会话读写图状态的第一道闸)。定时任务若用另一个 ID 命名法,就成了唯一
  6. 的例外,将来谁改了都会踩。用独立会话既同样隔离,又让用户能在会话列表里
  7. 直接翻到定时任务的产出——1.5 的定时任务结果只能去 schedule_runs 表里看。
  8. """
  9. from __future__ import annotations
  10. import asyncio
  11. from typing import Any
  12. from langchain_core.messages import AIMessage, HumanMessage
  13. import config
  14. from api.context_meter import extract_usage
  15. from billing import pricing
  16. from billing.meter import UsageMeter
  17. from db import chat_store, db_conn, schedule_store
  18. from obs import get_logger
  19. logger = get_logger(__name__)
  20. # 定时任务的图递归上限:比交互式对话紧得多。
  21. # 理由是没人盯着它——跑飞了只会白烧额度,早点中止更划算。
  22. SCHEDULED_RECURSION_LIMIT = 30
  23. def _extract_final_text(result: dict[str, Any]) -> str:
  24. """从图输出里取最后一条 AI 回复的正文。"""
  25. for message in reversed(result.get("messages") or []):
  26. if isinstance(message, AIMessage):
  27. content = message.content
  28. if isinstance(content, str) and content.strip():
  29. return content
  30. if isinstance(content, list):
  31. text = "".join(
  32. block.get("text", "")
  33. for block in content
  34. if isinstance(block, dict)
  35. )
  36. if text.strip():
  37. return text
  38. return ""
  39. def _sum_usage(messages: list[Any]) -> tuple[int, int, str]:
  40. """汇总本次执行的真实 token 用量。"""
  41. input_tokens = output_tokens = 0
  42. source = "provider"
  43. saw_provider = False
  44. for message in messages or []:
  45. usage = extract_usage(message)
  46. if usage is None:
  47. continue
  48. saw_provider = True
  49. input_tokens += usage["input_tokens"]
  50. output_tokens += usage["output_tokens"]
  51. if not saw_provider and messages:
  52. # provider 没给用量:标 estimate 而不是静默按 0 记账
  53. source = "estimate"
  54. return input_tokens, output_tokens, source
  55. async def run_schedule(schedule_id: str, trigger: str = "cron") -> dict[str, Any]:
  56. """执行一次定时任务。异常不外抛——调度器的回调抛异常会被 APScheduler
  57. 吞掉并只留一行日志,不如自己接住并写进 schedule_runs 让用户看得见。"""
  58. schedule = await asyncio.to_thread(schedule_store.get_schedule, schedule_id)
  59. if schedule is None:
  60. logger.warning("定时任务不存在 id=%s", schedule_id)
  61. return {"ok": False, "error": "schedule_not_found"}
  62. user_name = schedule.get("user_name") or "default"
  63. title = f"[定时] {schedule.get('title') or schedule_id[:8]}"
  64. session = await asyncio.to_thread(
  65. chat_store.create_session, user_name, schedule.get("mode") or "full", title
  66. )
  67. thread_id = session["id"]
  68. run_id = await asyncio.to_thread(schedule_store.start_run, schedule_id, thread_id)
  69. logger.info(
  70. "定时任务开始 id=%s run=%s trigger=%s thread=%s",
  71. schedule_id, run_id[:8], trigger, thread_id,
  72. )
  73. prompt = schedule.get("prompt") or ""
  74. await asyncio.to_thread(chat_store.save_message, thread_id, "user", prompt)
  75. try:
  76. from agents import agent_registry
  77. agent = agent_registry.get_agent()
  78. result = await agent.ainvoke(
  79. {"messages": [HumanMessage(content=prompt)]},
  80. config={
  81. "configurable": {"thread_id": thread_id},
  82. "recursion_limit": SCHEDULED_RECURSION_LIMIT,
  83. },
  84. )
  85. except Exception as exc:
  86. detail = f"{type(exc).__name__}: {exc}"
  87. logger.exception("定时任务失败 id=%s run=%s", schedule_id, run_id[:8])
  88. await asyncio.to_thread(
  89. chat_store.save_message, thread_id, "assistant", f"(任务执行失败){detail}"
  90. )
  91. await asyncio.to_thread(schedule_store.finish_run, run_id, "failed", detail, None)
  92. return {"ok": False, "run_id": run_id, "session_id": thread_id, "error": detail}
  93. output = _extract_final_text(result)
  94. await asyncio.to_thread(chat_store.save_message, thread_id, "assistant", output)
  95. await asyncio.to_thread(schedule_store.finish_run, run_id, "success", None, output[:2000])
  96. # 计费:定时任务同样消耗额度,不记就是白跑
  97. input_tokens, output_tokens, source = _sum_usage(result.get("messages") or [])
  98. if input_tokens or output_tokens:
  99. meter = UsageMeter(
  100. user_name=user_name,
  101. session_id=thread_id,
  102. kind="scheduled",
  103. model=config.MODEL,
  104. )
  105. meter.add(input_tokens, output_tokens, source)
  106. meter.settle()
  107. logger.info(
  108. "定时任务完成 id=%s run=%s 输出长度=%d tokens=%d/%d",
  109. schedule_id, run_id[:8], len(output), input_tokens, output_tokens,
  110. )
  111. return {
  112. "ok": True,
  113. "run_id": run_id,
  114. "session_id": thread_id,
  115. "output": output,
  116. "input_tokens": input_tokens,
  117. "output_tokens": output_tokens,
  118. }
  119. def ensure_pricing_seeded() -> None:
  120. """确保单价表就绪,避免定时任务因为模型没定价而 fail-closed。"""
  121. try:
  122. pricing.seed_default_pricing()
  123. except Exception as exc: # pragma: no cover - 取决于数据库可用性
  124. logger.warning("单价表初始化失败:%s: %s", type(exc).__name__, exc)
  125. __all__ = ["SCHEDULED_RECURSION_LIMIT", "ensure_pricing_seeded", "run_schedule", "db_conn"]