Files
ThearchyHelios ba28dee1f0 生成过程抽成 answer_ops、复核抽成 review_of_run,助手建图和复核的用量也记下(细案 01 做法 2、3):评测和问数据页走同一条生成路径,这一轮的花费不再低报
- api/copilot.py:event_stream 原样搬成 async generator answer_ops(payload, *, catalog_view=None),交出的 op dict 不变;开流前的准备(数据源、目录检查器、助手模型、历史、查看服务)用自己的短会话,在第一条操作之前做完。SSE 路由只包一层:先取第一条,开流前的拒绝(模型没配好、限定的数据源都不在)照旧是 HTTP 错误
- catalog_view 一路传给查看服务和自查的 SQL 检查器(目录视图接缝的「生成过程从参数读」)
- providers/usage.py:Meter 是一个 LangChain 回调,和 get_usage_metadata_callback 同一个机制(登记过的上下文变量),按模型累计 tokens、次数,金额按价格目录;估不出的记 unpriced_calls。没直接用 get_usage_metadata_callback:with 块跨 yield 会把变量留在调用方上下文里,调用方随后发起的运行会继承它、被重复计一遍;metered 只在每一步 __anext__ 期间挂上,嵌套时并进外层
- 生成过程读到 done / look / ask 就关模型的流(后面的闲话不要),流末尾才报的用量拿不到:这一次照样计次数和已报的 token,记成估不出,不悄悄少算(遗留:真实接入上建图那几次多半是下限)
- providers/budget.py:BudgetFallback.astream 用 aclosing 包住里面那条流——以前调用方关流时里面那条要等垃圾回收才关,模型那次调用的收尾(用量回调)落在算完账之后;测试去掉它回跑会少计一次
- answer_ops 末尾多一条 {op: usage, by_model, input_tokens, output_tokens, calls, cost_usd, unpriced_calls};/copilot/review 拆出 review_of_run(问数据页、评测共用),结果带 usage(没调模型时全 0)和 retry_instruction(要不要带着原图重来、说什么,按 chat/turn.py 的决定算;ReviewIn 多一个 attempt)
- 新增 app/chat/turn.py(问数据一轮的决定,复核这边先用上;final.next、对照测试、前端照做在下一个提交);新增 tests/test_copilot_answer_ops.py(八种结局的 SSE 输出和抽出来之前录下的 fixtures/copilot_answer_ops_baseline.json 逐条相同,末尾多一条 usage;直接迭代和 SSE 同一串;开流前的拒绝仍是 HTTP 错误)、tests/test_usage_meter.py;读 SSE 的十一个测试文件的收流帮手跳过末尾那条 usage
2026-10-16 10:45:45 +08:00

209 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""模型调用的输出额度:认出「输出到了上限被截断」,服务方嫌额度太大时退回小额度重来。
助手改图(api/copilot.py)、核查(engine/verify_agent.py:双盲复算、反方审稿、审批质询共用)和证据裁判(engine/judge.py)
共用这一份。会思考的模型思考也算在输出额度里:经 OpenAI 兼容接入的推理模型(DeepSeek、GLM、Kimi 这一类)收不到
thinking=off,Claude 开了 adaptive thinking 也一样。额度小了,想完就没额度写正文——回复是空的,或者 JSON 只写了一半。
这时要照实说是输出额度不够,不能当成「模型没按格式交」「没查到」。
- cut_off:这一块(流式)或这条回复是不是因为额度用完停下的
- truncated_error:这个异常是不是在说同一件事(openai 结构化输出解析时抛的 LengthFinishReasonError)
- budget_rejected:服务方一开口就嫌额度太大(助手改图按它认)
- budget_refused:核查、证据裁判按它认,多认自建服务说的 max_new_tokens 和 anthropic SDK 在本地的拒绝
- error_usage:截断的那次调用花掉的用量
- cut_words:给人看的「某某模型的输出达到上限(N token)被截断」
- BudgetFallback:先按大额度调,被嫌大时按小额度重来,之后都用小的;给了 key 的,被嫌大过就记在进程里
engine/nodes/llm.py(cite_fields 的抽取)、data/recipe_ai.py 各有一份同样的判断,是在这里之前写的,行为相同。
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable, Hashable
from contextlib import aclosing
from typing import Any
#: 服务方嫌额度太大时报错里会出现的字样:超过模型的输出上限(DeepSeek chat 系只到 8K),
#: 或者输入加额度超过上下文长度(本地 vLLM)。助手改图按这一份认,data/recipe_ai.py 的测试拿它比对
BUDGET_WORDS = ("max_tokens", "max_completion_tokens", "max_output_tokens", "maximum context length")
#: 核查、证据裁判另外认的:text-generation-inference 一类自建服务说的是 max_new_tokens(「`inputs` tokens +
#: `max_new_tokens` must be <= 8192」)。这两处的额度从 4096 提到 16384 之后才容易撞上;助手改图的认法不动
WIDER_BUDGET_WORDS = (*BUDGET_WORDS, "max_new_tokens")
#: anthropic SDK 在本地拒绝时的原话:客户端没设超时(Bearer 鉴权的接入,factory 换上的就是这种)、非流式调用、额度
#: 超过这个模型不流式能给的上限(Opus 4、4.1 这一代只到 8192)或估计要写十分钟以上,请求没发出去就抛 ValueError
LOCAL_REFUSAL = "streaming is required for operations that may take longer than 10 minutes"
#: 截断类报错的原话:openai 的 LengthFinishReasonError(结构化输出解析时 finish_reason=length)
LENGTH_WORDS = ("length limit was reached", "finish_reason=length", "finish_reason='length'")
def cut_off(chunk: Any) -> str:
"""这一块是不是在说「额度用完了」:OpenAI 系是 finish_reason=length,Anthropic 是 stop_reason=max_tokens。"""
meta = getattr(chunk, "response_metadata", None) or {}
reason = meta.get("finish_reason") or meta.get("stop_reason")
return str(reason) if reason in ("length", "max_tokens") else ""
def truncated_error(error: BaseException | None) -> bool:
"""这个异常是不是在说「输出被截断」:openai 的 LengthFinishReasonError,或者 langchain、网关转述的同一件事。
服务方嫌额度太大的 400(报错里也有 max_tokens)不算:那是请求被拒,模型一个字都没写。
"""
if error is None:
return False
if type(error).__name__ == "LengthFinishReasonError":
return True
text = str(error).lower()
return any(word in text for word in LENGTH_WORDS)
def budget_rejected(e: BaseException, words: tuple[str, ...] = BUDGET_WORDS) -> bool:
status = getattr(e, "status_code", None) or getattr(getattr(e, "response", None), "status_code", None)
return status in (400, 422) and any(w in str(e).lower() for w in words)
def budget_refused(e: BaseException) -> bool:
"""核查、证据裁判按这个认「额度被挡下了,换小的重来」:服务方嫌额度太大(多认 max_new_tokens),或者 anthropic SDK
在本地就拒了。两种都是一个字还没写、请求要么被拒要么根本没发出去,重来不会重复输出,也没花钱。助手改图走流式,
SDK 不在本地拦,仍按 budget_rejected 认。"""
if isinstance(e, ValueError) and LOCAL_REFUSAL in str(e).lower():
return True
return budget_rejected(e, WIDER_BUDGET_WORDS)
def error_usage(error: BaseException | None) -> tuple[int, int]:
"""失败的那次调用花掉的 (输入, 输出) token。截断时 openai 的异常带着 completion.usage:额度是真花出去了,要记账,
不然每日上限会漏掉最贵的那几次。别的异常没有用量,是 (0, 0)。"""
usage = getattr(getattr(error, "completion", None), "usage", None)
return int(getattr(usage, "prompt_tokens", 0) or 0), int(getattr(usage, "completion_tokens", 0) or 0)
def cut_words(who: str, limit: int | None) -> str:
"""「核查模型的输出达到上限(16384 token)被截断」。额度不知道(没给 max_tokens、按接入的默认值)就不写数。"""
return f"{who}的输出达到上限{f'({limit} token)' if limit else ''}被截断"
#: 嫌过大额度的接入:BudgetFallback 的 key(核查、证据裁判给的是 (接入, 模型, 大额度))。双盲复算的几路盲算、反方
#: 审稿的每一句、每次质询、每次点击裁判各是一组新建的调用,开关只在一组里共用;记在这里,之后新建的直接用小的,
#: 不再每组先撞一次。只记被拒过的;进程内有效,重启后再按大的试(服务方可能调高了上限),同 factory 的 _NO_STREAM_USAGE
_REJECTED: set[Hashable] = set()
class _Switch:
"""几次调用共用的「退回小额度了没有」:核查的工具循环和收尾、裁判的各批。rejected 是怎么认「额度被挡下了」;
key 给了,开关一开始就照 _REJECTED 定,退回时记进去。"""
__slots__ = ("key", "rejected", "small")
def __init__(self, key: Hashable | None, rejected: Callable[[BaseException], bool]) -> None:
self.key = key
self.rejected = rejected
self.small = key is not None and key in _REJECTED
def fall_back(self) -> None:
self.small = True
if self.key is not None:
_REJECTED.add(self.key)
class BudgetFallback:
"""先按大额度调;服务方一开口就嫌额度太大时按小额度重来,之后的调用都用小的。
只在还没吐出任何东西时重来:这类拒绝都发生在请求刚发出去的时候,重来不会重复输出。流式(astream)、非流式
(ainvoke)都认。bind_tools、with_structured_output 照样包一层,包出来的和原来的共用同一个开关;shared_with 给了,
另一份配置建的模型(核查收尾时关掉思考的那个)也和它共用:工具循环里退回过,收尾那次直接用小的,不再先撞一次。
small 是现成的小额度模型;也可以不给,给 build(无参的协程函数,返回小额度的模型),要退回时才建,平常一个都不多建。
limits 是 (大额度, 小额度):给了,max_tokens 就按它报(包过一层的模型身上不一定读得到额度);不给就读眼下那个模型的
max_tokens。rejected 是怎么认「额度被挡下了」,缺省 budget_rejected(助手改图),核查、证据裁判给 budget_refused。
key 给了(核查、证据裁判给 (接入, 模型, 大额度)),被嫌大一次就记在进程里,之后同一个 key 新建的一开始就用小的;
不给(助手改图)不记也不查。shared_with 给了,rejected、key 跟着它。
"""
def __init__(self, big: Any, small: Any = None, *, build: Callable[[], Awaitable[Any]] | None = None,
limits: tuple[int | None, int | None] | None = None,
shared_with: BudgetFallback | None = None,
rejected: Callable[[BaseException], bool] = budget_rejected,
key: Hashable | None = None) -> None:
self._big = big
self._small = small
self._build = build
self._limits = limits
self._switch = shared_with._switch if shared_with is not None else _Switch(key, rejected)
@property
def model(self) -> Any:
"""眼下用的那个:退回过、小额度的也建好了就是小的,否则是大的。"""
return self._small if self._switch.small and self._small is not None else self._big
@property
def max_tokens(self) -> Any:
if self._limits is not None:
return self._limits[1] if self._switch.small else self._limits[0]
return getattr(self.model, "max_tokens", None)
def _can_fall_back(self, used: Any) -> bool:
"""刚才那次用的是大额度、又有小额度可退。大小是同一个模型时重来也是同样的额度,不算可退。"""
if used is not self._big:
return False
return self._build is not None if self._small is None else self._small is not self._big
async def _small_model(self) -> Any:
if self._small is None and self._build is not None:
built = await self._build()
if self._small is None: # 并发的几次调用同时要建时,留先建好的那个
self._small = built
return self._small
async def _current(self) -> Any:
if self._switch.small and (small := await self._small_model()) is not None:
return small
return self._big
async def ainvoke(self, messages: Any, *args: Any, **kwargs: Any) -> Any:
used = await self._current()
try:
return await used.ainvoke(messages, *args, **kwargs)
except Exception as e:
if not self._can_fall_back(used) or not self._switch.rejected(e):
raise
self._switch.fall_back()
return await (await self._current()).ainvoke(messages, *args, **kwargs)
async def astream(self, messages: Any, *args: Any, **kwargs: Any):
# 里面那条流用 aclosing 包着:调用方读到一半不读了(助手读到 done、look 就关流),关这一层时里面那条也当场关掉、
# 模型那一次调用当场收尾(LangChain 的回调这时把已经收到的用量交出来,providers/usage.py)。不包的话里面那条要等
# 垃圾回收才关,收尾落在调用方算完账之后
used = await self._current()
started = False
try:
async with aclosing(used.astream(messages, *args, **kwargs)) as stream:
async for chunk in stream:
started = True
yield chunk
return
except Exception as e:
if started or not self._can_fall_back(used) or not self._switch.rejected(e):
raise
self._switch.fall_back()
async with aclosing((await self._current()).astream(messages, *args, **kwargs)) as stream:
async for chunk in stream:
yield chunk
def _derived(self, make: Callable[[Any], Any]) -> BudgetFallback:
"""对大小两个模型做同一件事(绑工具、要结构化输出),包成共用开关的一层。小的还没建时,建好再做。"""
small, build = None, None
if self._small is not None:
small = make(self._small)
elif self._build is not None:
async def build() -> Any:
return make(await self._small_model())
return BudgetFallback(make(self._big), small, build=build, limits=self._limits, shared_with=self)
def bind_tools(self, tools: Any, **kwargs: Any) -> BudgetFallback:
return self._derived(lambda m: m.bind_tools(tools, **kwargs))
def with_structured_output(self, schema: Any, **kwargs: Any) -> BudgetFallback:
return self._derived(lambda m: m.with_structured_output(schema, **kwargs))
__all__ = ["BUDGET_WORDS", "LENGTH_WORDS", "LOCAL_REFUSAL", "WIDER_BUDGET_WORDS", "BudgetFallback", "budget_refused",
"budget_rejected", "cut_off", "cut_words", "error_usage", "truncated_error"]