sse_core.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784
  1. """SSE 流式内核。
  2. 职责:把 LangGraph 的 ``astream`` 事件流翻译成 1.5 兼容的 SSE 帧,并在
  3. 结束时完成落库、计费、来源标注与上下文计量。
  4. ## 通道分工(重要,避免重复事件)
  5. ``stream_mode=["updates", "messages"]`` 两条通道都在推同一个 agent 的进展,
  6. 不加区分会导致同一个工具调用被发两次。这里的分工是:
  7. - **updates 通道**:工具调用/结果、待办清单、子智能体生命周期、状态提示。
  8. 走这里的原因是它给的是**完整的消息对象**(``tool_calls`` 里 args 已解析
  9. 完毕、``ToolMessage`` 已成对),不需要在流式增量里拼半个 JSON。
  10. - **messages 通道**:正文 token、思考 token、真实 token 用量。
  11. ``version="v2"`` 下每个 chunk 是 ``{"type", "ns", "data"}``;``subgraphs=True``
  12. 让子智能体的产出带 ``ns``。两个格式都做了兼容(旧版是 ``(mode, data)`` 元组)。
  13. ## 与 1.5 的差异
  14. 1.5 的 sse_core 是 1908 行,里面混着计费、压缩、Word 导出、来源刮取等多条
  15. 业务线。2.0 把这些拆成独立组件(context_meter / source_tracker /
  16. subagent_tracker),内核只负责「读流 → 派事件 → 收尾」。
  17. """
  18. from __future__ import annotations
  19. import asyncio
  20. import time
  21. from dataclasses import dataclass, field
  22. from pathlib import Path
  23. from typing import Any, AsyncIterator
  24. import config
  25. from api import concurrency
  26. from api.context_meter import ContextMeter
  27. from api.sse_events import EventWriter
  28. from api.source_tracker import SourceTracker
  29. from api.subagent_tracker import SubagentTracker
  30. from billing.meter import UsageMeter
  31. from db import chat_store
  32. from obs import get_logger, get_trace_id
  33. logger = get_logger(__name__)
  34. MAIN_AGENT_NAME = "deep-agent"
  35. MAIN_AGENT_CN = "深度智能体"
  36. # 摘要中间件的模型调用也在 messages 通道里推 token,靠这个标记过滤掉,
  37. # 否则摘要正文会混进给用户看的回答里
  38. _SUMMARIZATION_SOURCE = "summarization"
  39. @dataclass
  40. class StreamRequest:
  41. """一次流式请求的全部入参。"""
  42. agent: Any
  43. session_id: str
  44. thread_id: str
  45. config: dict[str, Any]
  46. message: str
  47. user_name: str = "default"
  48. kind: str = "chat"
  49. model: str = ""
  50. original_user_message: str = ""
  51. trace_id: str = ""
  52. slot: concurrency.TaskSlot | None = None
  53. started_at: float = field(default_factory=time.time)
  54. agent_name: str = MAIN_AGENT_NAME
  55. agent_cn_name: str = MAIN_AGENT_CN
  56. # 非 None 表示这是一次中断恢复:不组装消息,直接把 Command(resume=...) 喂回图
  57. resume_payload: Any = None
  58. def normalize_chunk(chunk: Any) -> tuple[str | None, tuple[Any, ...], Any]:
  59. """把不同版本的 astream chunk 归一成 ``(mode, ns, data)``。
  60. 兼容三种形态:v2 的 ``{"type","ns","data"}`` 字典、旧版 ``(mode, data)``
  61. 元组、以及无法识别的原样返回(mode=None,上层据此忽略)。
  62. """
  63. if isinstance(chunk, dict) and "data" in chunk and "type" in chunk:
  64. return str(chunk["type"]), tuple(chunk.get("ns") or ()), chunk["data"]
  65. if isinstance(chunk, tuple) and len(chunk) == 2 and isinstance(chunk[0], str):
  66. return chunk[0], (), chunk[1]
  67. return None, (), chunk
  68. def _message_text(message: Any) -> str:
  69. """把消息 content 拍平成纯文本(content 可能是字符串或内容块列表)。"""
  70. content = getattr(message, "content", None)
  71. if isinstance(content, str):
  72. return content
  73. if isinstance(content, list):
  74. parts: list[str] = []
  75. for block in content:
  76. if isinstance(block, str):
  77. parts.append(block)
  78. elif isinstance(block, dict):
  79. text = block.get("text")
  80. if isinstance(text, str):
  81. parts.append(text)
  82. return "".join(parts)
  83. return ""
  84. def _reasoning_text(message: Any) -> str:
  85. """抽取思考链文本(deepseek 的 reasoning_content)。"""
  86. extra = getattr(message, "additional_kwargs", None)
  87. if isinstance(extra, dict):
  88. value = extra.get("reasoning_content")
  89. if isinstance(value, str):
  90. return value
  91. return ""
  92. class ChatStream:
  93. """一次 /chat 请求的流式执行。"""
  94. def __init__(self, request: StreamRequest) -> None:
  95. self.req = request
  96. self.writer = EventWriter(request.session_id, request.trace_id or get_trace_id())
  97. self.meter = _meter_for_request()
  98. self.tracker = SubagentTracker()
  99. self.sources = SourceTracker()
  100. self.billing = UsageMeter(
  101. user_name=request.user_name,
  102. session_id=request.session_id,
  103. kind=request.kind,
  104. model=request.model or config.MODEL,
  105. trace_id=request.trace_id,
  106. )
  107. # 累计状态
  108. self.answer_parts: list[str] = []
  109. self.tool_calls: list[dict[str, Any]] = []
  110. self.todos: list[dict[str, Any]] = []
  111. self.interrupt_payloads: list[Any] = []
  112. self.finished = False
  113. # ========================================================
  114. # 主入口
  115. # ========================================================
  116. async def generate(self) -> AsyncIterator[str]:
  117. """产出整个 SSE 流。保证:计费幂等入账、并发位释放、图游标关闭。
  118. 这里显式绑定 trace_id 而不是依赖 HTTP 中间件设的 ContextVar:
  119. 响应体的迭代在 Starlette 的中间件调用栈之外执行,中间件里绑的值
  120. 在生成器里不保证可见。绑定发生在生成器内部,生命周期与本次流一致。
  121. """
  122. from obs import bind_trace_id, unbind_trace_id
  123. token = bind_trace_id(self.req.trace_id or None)
  124. stream = None
  125. try:
  126. yield self.writer.agent_start(self.req.agent_name, self.req.agent_cn_name)
  127. yield self.writer.thinking("正在理解您的问题...", self.req.agent_cn_name)
  128. graph_input = await self._prepare_input()
  129. stream = self.req.agent.astream(
  130. graph_input,
  131. stream_mode=["updates", "messages"],
  132. config=self.req.config,
  133. subgraphs=True,
  134. version="v2",
  135. )
  136. async for frame in self._drive(stream):
  137. yield frame
  138. # 中断检测必须在流结束后做:interrupt 会让流自然结束
  139. async for frame in self._emit_interrupts():
  140. yield frame
  141. async for frame in self._completion():
  142. yield frame
  143. except asyncio.CancelledError:
  144. # 客户端断连:不当作错误,但要把已产出的部分落库并结算
  145. logger.info("客户端断开连接 session=%s", self.req.session_id)
  146. async for frame in self._completion(aborted=True):
  147. yield frame
  148. raise
  149. except GeneratorExit:
  150. logger.info("生成器被关闭 session=%s", self.req.session_id)
  151. async for frame in self._completion(aborted=True):
  152. yield frame
  153. raise
  154. except Exception as exc:
  155. async for frame in self._error_frames(exc):
  156. yield frame
  157. finally:
  158. if stream is not None:
  159. close = getattr(stream, "aclose", None)
  160. if close is not None:
  161. try:
  162. await close()
  163. except Exception: # pragma: no cover - 关闭失败不影响结算
  164. pass
  165. self._finalize()
  166. unbind_trace_id(token)
  167. # ========================================================
  168. # 输入准备
  169. # ========================================================
  170. async def _prepare_input(self) -> Any:
  171. """组装喂给图的输入。
  172. 两种情形:
  173. - **恢复**(``resume_payload`` 非 None):喂 ``Command(resume=...)``,
  174. 不组装消息——图会从挂起的那个节点继续。
  175. - **新请求**:热线程(checkpoint 里已有状态)只喂当轮新消息;冷线程
  176. 需要从会话历史补种。
  177. 补种这一步不能省,也不能对所有线程都做:热线程若把历史重复追加,
  178. 上下文会随轮次按 O(N²) 膨胀——这是长会话最容易被忽略的膨胀来源。
  179. """
  180. if self.req.resume_payload is not None:
  181. from langgraph.types import Command
  182. logger.info("恢复中断 session=%s", self.req.session_id)
  183. return Command(resume=self.req.resume_payload)
  184. from langchain_core.messages import AIMessage, HumanMessage
  185. from agents import agent_memory
  186. messages: list[Any] = []
  187. warm = await agent_memory.has_checkpoint(self.req.thread_id)
  188. if not warm:
  189. history = chat_store.get_messages(
  190. self.req.session_id, limit=config.CTX_RECENT_LIMIT
  191. )
  192. for row in history:
  193. content = row.get("content") or ""
  194. if not content:
  195. continue
  196. if row.get("msg_role") == "user":
  197. messages.append(HumanMessage(content=content))
  198. else:
  199. messages.append(AIMessage(content=content))
  200. if messages:
  201. logger.info(
  202. "冷线程补种历史 session=%s 条数=%d", self.req.session_id, len(messages)
  203. )
  204. else:
  205. logger.info("热线程,仅喂当轮消息 session=%s", self.req.session_id)
  206. # 用户消息先落库:即使这一轮流到一半挂了,历史里也得有用户问了什么
  207. chat_store.save_message(self.req.session_id, "user", self.req.message)
  208. self.meter.note_text(self.req.message)
  209. messages.append(HumanMessage(content=self.req.message))
  210. # 必须是 {"messages": [...]} 字典:直接传 list 会被当成 __start__ 节点的
  211. # 返回值,图会报 InvalidUpdateError: Expected dict
  212. return {"messages": messages}
  213. # ========================================================
  214. # 流驱动
  215. # ========================================================
  216. async def _drive(self, stream: Any) -> AsyncIterator[str]:
  217. """消费 astream,派发事件帧。带墙钟上限。"""
  218. deadline = self.req.started_at + config.TASK_WALL_TIMEOUT
  219. timed_out = False
  220. iterator = stream.__aiter__()
  221. while True:
  222. if time.time() > deadline:
  223. timed_out = True
  224. break
  225. try:
  226. chunk = await asyncio.wait_for(
  227. iterator.__anext__(), timeout=max(deadline - time.time(), 1.0)
  228. )
  229. except StopAsyncIteration:
  230. break
  231. except asyncio.TimeoutError:
  232. timed_out = True
  233. break
  234. mode, ns, data = normalize_chunk(chunk)
  235. if mode == "updates":
  236. for frame in self._handle_updates(data, ns):
  237. yield frame
  238. elif mode == "messages":
  239. for frame in self._handle_messages(data, ns):
  240. yield frame
  241. # mode 为 None:无法识别的 chunk,忽略而不是猜
  242. if timed_out:
  243. raise concurrency.TaskWallTimeoutError(
  244. f"任务超过墙钟上限 {config.TASK_WALL_TIMEOUT}s 已被中止"
  245. )
  246. # 子智能体可能因为异常/中断没收到结果,收尾时统一关掉,避免前端卡片长转
  247. for run in self.tracker.finish_all():
  248. yield self.writer.progress(
  249. f"子智能体 {run.subagent_type} 未正常返回,已中止",
  250. subagent=run.subagent_type, subagent_id=run.call_id,
  251. )
  252. # -------- updates 通道 --------
  253. def _handle_updates(self, data: Any, ns: tuple[Any, ...]) -> list[str]:
  254. """处理 updates 通道:工具事件、待办、子智能体台账。"""
  255. from langchain_core.messages import AIMessage, ToolMessage
  256. frames: list[str] = []
  257. if not isinstance(data, dict):
  258. return frames
  259. # 中断会以 __interrupt__ 键出现在 updates 里,比 aget_state 更及时
  260. if "__interrupt__" in data:
  261. for item in data.get("__interrupt__") or ():
  262. self.interrupt_payloads.append(getattr(item, "value", item))
  263. return frames
  264. # 子智能体身份:updates 的 ns 指向它所属的 task 调用
  265. run = self.tracker.note_namespace(ns) if ns else None
  266. subagent_type = run.subagent_type if run else ""
  267. subagent_id = run.call_id if run else ""
  268. for node, update in data.items():
  269. if not isinstance(update, dict):
  270. continue
  271. todos = update.get("todos")
  272. if isinstance(todos, list):
  273. self.todos = [t for t in todos if isinstance(t, dict)]
  274. frames.append(self.writer.todo_list(self.todos))
  275. for message in update.get("messages") or ():
  276. if isinstance(message, ToolMessage):
  277. frames.extend(self._on_tool_message(message, subagent_type, subagent_id))
  278. elif isinstance(message, AIMessage):
  279. frames.extend(self._on_ai_message(message, subagent_type, subagent_id))
  280. return frames
  281. def _on_ai_message(
  282. self, message: Any, subagent_type: str, subagent_id: str
  283. ) -> list[str]:
  284. """处理一条完整的 AIMessage:登记 task 调用、发工具调用卡。"""
  285. from tools.registry import get_label
  286. frames: list[str] = []
  287. tool_calls = list(getattr(message, "tool_calls", None) or [])
  288. # 先让跟踪器登记 task 调用,这样后续子智能体事件能立刻带上身份
  289. for run in self.tracker.note_task_calls(tool_calls):
  290. frames.append(
  291. self.writer.progress(
  292. f"委派子智能体:{run.subagent_type}",
  293. subagent=run.subagent_type,
  294. subagent_id=run.call_id,
  295. description=run.description,
  296. )
  297. )
  298. names = [c.get("name", "") for c in tool_calls if isinstance(c, dict)]
  299. if names:
  300. frames.append(
  301. self.writer.executing(
  302. f"调用工具:{'、'.join(get_label(n) for n in names)}",
  303. cn_agent=self.req.agent_cn_name,
  304. tools=names,
  305. cn_tools=[get_label(n) for n in names],
  306. )
  307. )
  308. for call in tool_calls:
  309. if not isinstance(call, dict):
  310. continue
  311. name = call.get("name") or ""
  312. if not name:
  313. continue
  314. args = call.get("args")
  315. self.tool_calls.append({"name": name, "args": args})
  316. frames.append(
  317. self.writer.tool_call(
  318. name,
  319. args=args,
  320. cn_tool=get_label(name),
  321. agent_id=subagent_id,
  322. tool_call_id=str(call.get("id") or ""),
  323. )
  324. )
  325. return frames
  326. def _on_tool_message(
  327. self, message: Any, subagent_type: str, subagent_id: str
  328. ) -> list[str]:
  329. """处理一条 ToolMessage:发工具结果卡、抽取来源、结子智能体。"""
  330. from tools.registry import get_label
  331. frames: list[str] = []
  332. name = getattr(message, "name", None) or "tool"
  333. content = _message_text(message)
  334. # task 的返回即子智能体结束
  335. call_id = getattr(message, "tool_call_id", None) or ""
  336. if name == "task" and call_id:
  337. run = self.tracker.note_result(call_id, ok=True)
  338. if run is not None:
  339. frames.append(
  340. self.writer.agent_done(
  341. run.subagent_type,
  342. cn_agent=run.subagent_type,
  343. duration_ms=run.duration_ms,
  344. progress="已完成",
  345. agent_id=run.call_id,
  346. )
  347. )
  348. self.meter.note_text(content)
  349. self.sources.note_tool_result(name, content)
  350. frames.append(
  351. self.writer.tool_result(
  352. name,
  353. content=content[:4000],
  354. cn_tool=get_label(name),
  355. agent_id=subagent_id,
  356. error=str(getattr(message, "status", "") or "") == "error",
  357. tool_call_id=call_id,
  358. )
  359. )
  360. return frames
  361. # -------- messages 通道 --------
  362. def _handle_messages(self, data: Any, ns: tuple[Any, ...]) -> list[str]:
  363. """处理 messages 通道:正文 token、思考 token、用量计量。"""
  364. from langchain_core.messages import AIMessageChunk
  365. frames: list[str] = []
  366. if not isinstance(data, tuple) or len(data) != 2:
  367. return frames
  368. chunk, metadata = data
  369. metadata = metadata if isinstance(metadata, dict) else {}
  370. # 摘要中间件的模型调用也在这条通道上:它的 token 不能给用户看
  371. source = metadata.get("lc_source")
  372. if source == _SUMMARIZATION_SOURCE:
  373. return frames
  374. # 用量计量对所有 chunk 都做(包括子智能体的),口径才完整
  375. self.meter.note_message(chunk)
  376. # 只有 AIMessageChunk 的内容才是「模型正在说的话」。ToolMessage 也走
  377. # 这条通道,它的 content 是工具返回的原始数据——不加这个判断,工具
  378. # 结果的 JSON 会混进给用户看的正文里。
  379. if not isinstance(chunk, AIMessageChunk):
  380. return frames
  381. reasoning = _reasoning_text(chunk)
  382. if reasoning:
  383. frames.append(self.writer.thinking_token(reasoning))
  384. text = _message_text(chunk)
  385. if text:
  386. self.answer_parts.append(text)
  387. frames.append(self.writer.token(text))
  388. return frames
  389. # ========================================================
  390. # 中断
  391. # ========================================================
  392. async def _emit_interrupts(self) -> AsyncIterator[str]:
  393. """把中断转成 1.5 形状的 interrupt 事件。
  394. 两个来源:流里捕获到的 ``__interrupt__``,以及流结束后从图状态里
  395. 读出的 pending interrupt。优先用前者(更及时),两者都空则无中断。
  396. """
  397. payloads = list(self.interrupt_payloads)
  398. if not payloads:
  399. payloads = await self._interrupts_from_state()
  400. if not payloads:
  401. return
  402. for payload in payloads:
  403. frame = self._adapt_interrupt(payload)
  404. if frame:
  405. yield frame
  406. async def _interrupts_from_state(self) -> list[Any]:
  407. """从图状态里读出挂起的中断载荷。"""
  408. get_state = getattr(self.req.agent, "aget_state", None)
  409. if get_state is None:
  410. return []
  411. try:
  412. state = await get_state(self.req.config)
  413. except Exception as exc: # pragma: no cover - 取决于底层库
  414. logger.warning("读取图状态失败:%s: %s", type(exc).__name__, exc)
  415. return []
  416. found: list[Any] = []
  417. for task in getattr(state, "tasks", None) or ():
  418. for item in getattr(task, "interrupts", None) or ():
  419. found.append(getattr(item, "value", item))
  420. return found
  421. def _adapt_interrupt(self, payload: Any) -> str | None:
  422. """官方 HITL 载荷 → 1.5 的 interrupt 事件。
  423. 官方形状是 ``{"action_requests":[{name,args,description}], "review_configs":[...]}``;
  424. 1.5 前端要的是 ``tool_approval{tool,args,reason,side_effect}``。
  425. 这个转换是「协议完全兼容」的落点,改动前先看 sse_events 的契约表。
  426. """
  427. from tools.registry import get_label
  428. if not isinstance(payload, dict):
  429. return self.writer.interrupt_raw(message=str(payload)[:200])
  430. requests = payload.get("action_requests")
  431. if not isinstance(requests, list) or not requests:
  432. # 不是工具审批类中断:给一条通用提示,别让前端静默卡住
  433. return self.writer.interrupt_raw(
  434. message="智能体暂停,等待输入", payload=payload
  435. )
  436. # 官方把同一轮的多个待审批调用合并成一个中断,这里取第一个作为展示主体,
  437. # 其余挂在 extra 里——1.5 的审批卡一次只渲染一个工具
  438. first = requests[0] if isinstance(requests[0], dict) else {}
  439. tool_name = str(first.get("name") or "")
  440. return self.writer.interrupt_tool_approval(
  441. tool=tool_name,
  442. args=first.get("args"),
  443. reason=str(first.get("description") or ""),
  444. side_effect="该操作需人工确认后才会执行",
  445. cn_tool=get_label(tool_name),
  446. pending_count=len(requests),
  447. )
  448. # ========================================================
  449. # 收尾
  450. # ========================================================
  451. async def _completion(self, aborted: bool = False) -> AsyncIterator[str]:
  452. """落库 → 计费 → 来源 → 上下文用量 → agent_done/done。
  453. 无论正常结束、异常还是断连都走这里(幂等,重复调用只生效一次)。
  454. """
  455. if self.finished:
  456. return
  457. self.finished = True
  458. duration_ms = int((time.time() - self.req.started_at) * 1000)
  459. answer = "".join(self.answer_parts)
  460. if aborted and answer:
  461. answer += "\n\n(本次回复被中断,内容可能不完整)"
  462. # 1) 上下文用量快照。**只取一次**:snapshot() 会消费掉压缩存档的待办标记,
  463. # 取两次会导致第二次拿不到存档内容。
  464. self._apply_estimate_fallback()
  465. snapshot = self.meter.snapshot()
  466. # 2) 计费:把上下文计量器的数字转给计费计量器。
  467. # 这是两个计量器唯一的连接点——漏了这一步,交互式对话全程不会计费,
  468. # 而 /usage/today 会显示 0,表面上「一切正常」。
  469. self.billing.add(snapshot.input_tokens, snapshot.output_tokens, snapshot.usage_source)
  470. # 3) 落库:assistant 消息是会话历史的事实源
  471. citations = self.sources.build_citations()
  472. if answer or citations:
  473. try:
  474. chat_store.save_message(
  475. self.req.session_id,
  476. "assistant",
  477. answer,
  478. tool_calls=self.tool_calls or None,
  479. citations=citations or None,
  480. )
  481. except Exception as exc:
  482. logger.error("落库失败 session=%s:%s: %s", self.req.session_id, type(exc).__name__, exc)
  483. # 4) 上下文用量 + 压缩存档
  484. await self._persist_context(snapshot)
  485. # 5) 计费入账(幂等)
  486. self.billing.settle()
  487. # 6) 来源标注
  488. yield self.writer.citations(citations, model_only=not citations)
  489. if aborted:
  490. yield self.writer.done(duration_ms=duration_ms, aborted=True)
  491. return
  492. yield self.writer.agent_done(
  493. self.req.agent_name,
  494. cn_agent=self.req.agent_cn_name,
  495. duration_ms=duration_ms,
  496. progress="已完成",
  497. **self.meter.summary_fields(),
  498. )
  499. yield self.writer.done(duration_ms=duration_ms, usage=self._usage_payload())
  500. def _usage_payload(self) -> dict[str, Any]:
  501. """给前端的用量摘要(便于测试页显示,1.5 前端会忽略未知字段)。"""
  502. return {
  503. "input_tokens": self.meter.input_tokens,
  504. "output_tokens": self.meter.output_tokens,
  505. "total_tokens": self.meter.total_tokens,
  506. "usage_source": self.meter.usage_source,
  507. "context_tokens": self.meter.context_tokens,
  508. }
  509. def _apply_estimate_fallback(self) -> None:
  510. """provider 没返回任何 usage 时,用缓冲区文本估算兜底。
  511. 不兜底的话,这类请求会以 0 token 入账——用户白用、额度不受控,
  512. 而且从账面上完全看不出来。兜底后至少 usage_source=estimate 能
  513. 标示这批数字不可尽信。
  514. """
  515. if self.meter.total_tokens > 0:
  516. return
  517. estimated = self.meter.estimate_from_buffer()
  518. if estimated > 0:
  519. logger.warning(
  520. "本次未取到 provider usage,按缓冲区估算 %d tokens(usage_source=estimate)"
  521. " session=%s",
  522. estimated, self.req.session_id,
  523. )
  524. self.meter.note_estimated(estimated)
  525. async def _persist_context(self, snapshot: Any) -> None:
  526. """落 session_context_usage,并在发生压缩时登记原文存档位置。
  527. 存档优先用**框架自己的产物**:摘要中间件会把被淘汰的历史追加写到
  528. ``/conversation_history/{thread_id}.md``,并把路径嵌进摘要正文——
  529. 那份文件是权威的,agent 也能自己读回来。只有框架产物不存在时才
  530. 回落写我们自己的缓冲区,避免「以为有存档其实没有」。
  531. """
  532. archive_path = ""
  533. try:
  534. if snapshot.compacted:
  535. archive_path = await asyncio.to_thread(
  536. self._archive_compaction, snapshot.archive_text, snapshot.archive_tokens
  537. )
  538. chat_store.save_context_usage(
  539. session_id=self.req.session_id,
  540. turn=chat_store.count_messages(self.req.session_id) // 2,
  541. input_tokens=snapshot.input_tokens,
  542. output_tokens=snapshot.output_tokens,
  543. cache_read_tokens=snapshot.cache_read_tokens,
  544. usage_source=snapshot.usage_source,
  545. compacted=snapshot.compacted,
  546. compact_count=snapshot.compact_count,
  547. snapshot={
  548. "context_tokens": snapshot.context_tokens,
  549. "context_window": self.meter.window,
  550. "model_calls": snapshot.model_calls,
  551. "archive_path": archive_path,
  552. },
  553. )
  554. except Exception as exc:
  555. logger.error(
  556. "上下文用量落库失败 session=%s:%s: %s",
  557. self.req.session_id, type(exc).__name__, exc,
  558. )
  559. def _archive_compaction(self, buffered_text: str | None, buffered_tokens: int) -> str:
  560. """登记(必要时写出)压缩前原文,返回存档路径或空串。"""
  561. history_dir = config.ROOT / "conversation_history"
  562. framework_file = history_dir / f"{self.req.thread_id}.md"
  563. if framework_file.exists():
  564. path = str(framework_file)
  565. chat_store.save_context_archive(
  566. self.req.session_id, 0, path, buffered_tokens
  567. )
  568. logger.info("压缩原文由框架落盘:%s", path)
  569. return path
  570. if not buffered_text:
  571. logger.warning(
  572. "检测到压缩但既无框架落盘也无本地缓冲,无法存档 session=%s",
  573. self.req.session_id,
  574. )
  575. return ""
  576. archive_dir = config.ROOT / "data" / "context_archives"
  577. archive_dir.mkdir(parents=True, exist_ok=True)
  578. stamp = time.strftime("%Y%m%d-%H%M%S")
  579. target = archive_dir / f"{self.req.session_id}-{stamp}.md"
  580. target.write_text(buffered_text, encoding="utf-8")
  581. chat_store.save_context_archive(
  582. self.req.session_id, 0, str(target), buffered_tokens
  583. )
  584. logger.info("压缩原文由本进程落盘:%s(%d tokens)", target, buffered_tokens)
  585. return str(target)
  586. # ========================================================
  587. # 异常
  588. # ========================================================
  589. async def _error_frames(self, exc: Exception) -> AsyncIterator[str]:
  590. """把异常翻译成 error 帧 + 收尾帧。"""
  591. from langgraph.errors import GraphRecursionError
  592. if isinstance(exc, GraphRecursionError):
  593. logger.warning("图递归上限触发 session=%s:%s", self.req.session_id, exc)
  594. yield self.writer.error(
  595. "任务步骤过多已被中止,请把任务拆成更小的步骤重试", code="recursion_limit"
  596. )
  597. async for frame in self._completion(aborted=True):
  598. yield frame
  599. return
  600. if isinstance(exc, concurrency.TaskWallTimeoutError):
  601. logger.warning("任务墙钟超时 session=%s:%s", self.req.session_id, exc)
  602. yield self.writer.error(str(exc), code="task_timeout")
  603. async for frame in self._completion(aborted=True):
  604. yield frame
  605. return
  606. logger.exception("流式执行异常 session=%s", self.req.session_id)
  607. yield self.writer.error(
  608. f"服务内部错误:{type(exc).__name__}: {exc}", code="internal_error"
  609. )
  610. async for frame in self._completion(aborted=True):
  611. yield frame
  612. # ========================================================
  613. # 资源
  614. # ========================================================
  615. def _finalize(self) -> None:
  616. """释放并发位并兜底结算。幂等。"""
  617. try:
  618. if not self.billing.settled:
  619. self.billing.settle()
  620. finally:
  621. if self.req.slot is not None:
  622. self.req.slot.release()
  623. def _meter_for_request() -> ContextMeter:
  624. """给本轮请求建一个上下文计量器,窗口取自模型配置。"""
  625. from agents import model_factory
  626. try:
  627. window = model_factory.context_window()
  628. except Exception: # pragma: no cover - 配置异常不该阻断对话
  629. window = 0
  630. return ContextMeter(window=window)
  631. def sse_headers(session_id: str) -> dict[str, str]:
  632. """SSE 响应头。``X-Accel-Buffering: no`` 是给 nginx 的:不关掉的话
  633. 反代会缓冲整个响应,流式效果全无(表现为「一次性吐出全部内容」)。"""
  634. return {
  635. "Cache-Control": "no-cache",
  636. "Connection": "keep-alive",
  637. "X-Accel-Buffering": "no",
  638. "X-Session-Id": session_id,
  639. }
  640. async def pending_interrupts(agent: Any, config: dict[str, Any]) -> list[Any]:
  641. """读取当前挂起的中断载荷列表。
  642. 供两处复用:``GET /chat/interrupt``(刷新页面后补渲染卡片)与
  643. ``POST /chat/resume``(在构造 decisions 前先知道有几个待审批动作——
  644. 官方 HITL 要求 decisions 与 action_requests **数量严格一一对应**,
  645. 数量对不上会直接抛错)。
  646. """
  647. get_state = getattr(agent, "aget_state", None)
  648. if get_state is None:
  649. return []
  650. try:
  651. state = await get_state(config)
  652. except Exception as exc:
  653. logger.warning("读取图状态失败:%s: %s", type(exc).__name__, exc)
  654. return []
  655. found: list[Any] = []
  656. for task in getattr(state, "tasks", None) or ():
  657. for item in getattr(task, "interrupts", None) or ():
  658. found.append(getattr(item, "value", item))
  659. return found