mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:31:58 +00:00
e3f5d566d0
为什么(复核第 1 轮):没开始的句子、指标和收紧后到点的,以前和别的原因记在同一条日志里,没有可机读的字段,界面只能照旧叫人调高每句、每项的上限(调高了只会更快撞线),或去查核查模型和数据源(它们本来就没问题);前面有一句因别的原因没审时,运行时长上限这个原因还被藏进「等」里。双盲复算只给下游留 60 秒,惯常接法「双盲复算 → 写报告 → 反方审稿 → 成果」里写报告一慢,运行照样在写报告时被中止;时长上限调到 180 秒左右时,固定的 60 秒余量又让核查一项都开始不了。 - 反方审稿:没开始的句子另发一条 challenge_unreviewed,带 cause、run_seconds,不混进别的原因;收紧后到点的那条 challenge_limit 照记 timeout_s(老界面认得),另带 cause、run_seconds 和触顶的句数 claims,同一节点里有句子触了自己的上限时两条分得开。产出的 unreviewed 照算 - 双盲复算:没开始、收紧后到点的盲算结果带 cause、run_seconds,_compare 记无法复算时一并带上(只这一行),dual_check_unchecked 按有无 cause 分两条发,时限将至的那条另带 cause、run_seconds,原话列指标名(最多 5 个) - deadline.relabel 包住交给核查 agent 的 emit:收紧过的那一项到点时,verify_limit 改说「本次运行即将达到 600 秒的时长上限,时长已按剩余时间收紧到 52.3 秒」,带 cause;别的事件、别的上限原样转发,没收紧时原样交回 - 余量按份算:一份取 60 秒和这一段的 10% 中大的,这一段不到 240 秒时按 25%;下游这一段里(到人工审批为止)每个还要调模型的节点(模型、Agent、协作、子工作流、写报告、双盲复算、反方审稿)各再加一份,合计不超过这一段的一半。默认 600 秒的一段、下游只有成果节点时和以前一样 - 没有截止时刻时一切照旧;决定都还在原来的 @task 里,接着运行取 checkpoint 测试:反方审稿、双盲复算各补一条「另一个原因 + 时限将至」两条分开说、带不带 cause 的用例,反方审稿补一条触了自己上限和收紧后到点分得开的用例;原有用例补上 cause、run_seconds、原话的断言。假的钟钉住下游有写报告时第 3 项只查 40~50 秒(没有时 100~110 秒);真的钟用 10 秒一段、盲算查到时长用完、写报告要 3 秒,以前在写报告时被中止,现在运行成功。单元测试钉住短时长的余量、下游每个节点加一份和一半的上限、数下游节点(人工审批之后不算、汇合只算一次、环回自己不算)、relabel 改什么不改什么。去掉下游余量、去掉 relabel 后对应用例都变红。
168 lines
10 KiB
Python
168 lines
10 KiB
Python
"""这一段执行的截止时刻:核查节点据此在运行的时长上限之内收尾,不让整次运行被中止。
|
||
|
||
runner 给每一段执行(两次人工介入之间的那一段)套一道 asyncio.timeout(max_run_seconds),到点整次运行判失败。核查
|
||
节点一个就能把这一段的时间用光:反方审稿逐句串行反查,默认 5 句、每句 90 秒再加 30 秒收尾宽限,最坏就是 600 秒;双盲
|
||
复算每项 120 秒加宽限,指标多了也一样。被中止时节点里装着反查、盲算的那个 @task 还没做完、没进 checkpoint,「继续
|
||
运行」把它从头再做一遍,多花一份钱,再次超时。
|
||
|
||
所以 runner 进入时长上限时把截止时刻放进这里的 ContextVar(segment),退出时清掉:节点、节点里的 @task、子工作流都
|
||
在这个上下文里执行,都看得到。核查节点每开始一项(一句反查、一项盲算)之前问 allot():给下游留出余量之后还够不够
|
||
再做一项,这一项的时长上限收紧到多少;不够就不再开始,剩下的照各自「没能核查」的路径记下来,节点正常收尾。决定都
|
||
发生在节点自己的 @task 里,接着运行时取 checkpoint 里的结果,重放确定。
|
||
|
||
截止时刻用事件循环的钟(loop.time()),和 asyncio.timeout 同一个钟。没有上限时(不经 runner 执行的节点:单元测试、
|
||
审批质询接口)为 None,allot() 原样放行,节点的行为和没有这道检查时一样。
|
||
|
||
因此没核成的,节点发日志时带上 cause: RUN_DEADLINE 和这一段的总时长(run_seconds),另起一条,不和别的原因混在一起:
|
||
界面据此说「运行时长上限将至」、给对的下一步——调高核查自己的上限只会更快撞线,检查模型、数据源也无济于事。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import contextlib
|
||
import math
|
||
from collections.abc import AsyncIterator, Callable
|
||
from contextvars import ContextVar
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
from app.core.events import EventType
|
||
from app.engine.schema import GraphSpec, NodeType
|
||
|
||
#: 给下游节点留的余量,按份算。一份给收尾的节点:核查节点后面通常还有成果节点(出具契约复核报告),核查把时间用到
|
||
#: 最后一刻,后面的节点一开始就撞上时长上限,整次运行照样被中止。一份取 RESERVE_S 和这一段总时长的 RESERVE_SHARE 中
|
||
#: 大的:60 秒够成果节点复核、再做一两次普通的模型调用;管理员调大了时长上限的,多半是因为节点本来就慢,余量跟着放大
|
||
RESERVE_S = 60.0
|
||
RESERVE_SHARE = 0.1
|
||
#: 这一段不到 RESERVE_S / SHORT_SHARE(240 秒)时,一份按总时长的 SHORT_SHARE 算:时长上限调得很短(比如 180 秒)时,
|
||
#: 固定的 60 秒余量加上收尾宽限,前面的节点用掉一分钟核查就一项都开始不了,比没有这道检查时还差
|
||
SHORT_SHARE = 0.25
|
||
#: 下游这一段里每个还要调模型的节点再各加一份:Copilot 指南里的惯常接法是「口径卡 → 双盲复算 → 写报告 → 反方审稿 →
|
||
#: 成果」,双盲复算只留一份的话,写报告那次模型调用一慢,运行就在写报告时被中止。反方审稿也记一份:默认 600 秒的一段
|
||
#: 一份是 60 秒,正好够它开始一句(它自己那份余量之外,还要收尾宽限和最短的查库时长各 30 秒)。估得粗,宁可多留:
|
||
#: 核查少做几项记成没核成,好过整次运行被中止
|
||
MODEL_NODES = frozenset({NodeType.LLM, NodeType.AGENT, NodeType.SUPERVISOR, NodeType.SUBGRAPH, NodeType.REPORT,
|
||
NodeType.DUAL_CHECK, NodeType.CHALLENGE})
|
||
#: 余量合计最多占这一段总时长的这么多:下游节点再多,核查节点也总还能开始几项
|
||
RESERVE_CAP = 0.5
|
||
#: 日志里的 cause:这几项没核成是因为运行时长上限将至(不再开始、收紧后到了点)
|
||
RUN_DEADLINE = "run_deadline"
|
||
#: 收紧过的核查到了点:核查 agent 自己发的 verify_limit 改成这一句(relabel)
|
||
SQUEEZED_LIMIT = ("核查已达上限(本次运行即将达到 {seconds:g} 秒的时长上限,时长已按剩余时间收紧到 {timeout:g} 秒),"
|
||
"已停止查询,按已有结果收尾")
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Segment:
|
||
"""这一段执行:截止时刻(事件循环时间)和总时长(秒,说明里写、按它算余量)。"""
|
||
|
||
deadline: float
|
||
seconds: float
|
||
|
||
|
||
_SEGMENT: ContextVar[Segment | None] = ContextVar("agentlab_segment", default=None)
|
||
|
||
|
||
@contextlib.asynccontextmanager
|
||
async def segment(deadline: float | None, seconds: float) -> AsyncIterator[None]:
|
||
"""runner 用:这一段执行的截止时刻(asyncio.Timeout.when())和总时长,退出时清掉。写成异步的上下文管理器,好和
|
||
时长上限写在同一个 async with 里。deadline 为 None(没有上限)时放 None:里面看不到外面的截止时刻。"""
|
||
token = _SEGMENT.set(Segment(float(deadline), float(seconds)) if deadline is not None else None)
|
||
try:
|
||
yield
|
||
finally:
|
||
_SEGMENT.reset(token)
|
||
|
||
|
||
def current() -> Segment | None:
|
||
"""这一段执行的截止时刻;不在 runner 的执行里时为 None。"""
|
||
return _SEGMENT.get()
|
||
|
||
|
||
def reserve(seconds: float, after: int = 0) -> float:
|
||
"""给下游节点留的余量(秒):一份给收尾的节点,after 是下游这一段里还要调模型的节点数(models_after),各再加一份;
|
||
合计不超过总时长的 RESERVE_CAP。一份多大、为什么,见 RESERVE_S、SHORT_SHARE、MODEL_NODES。"""
|
||
share = max(min(RESERVE_S, SHORT_SHARE * seconds), RESERVE_SHARE * seconds)
|
||
return min(share * (1 + max(after, 0)), RESERVE_CAP * seconds)
|
||
|
||
|
||
def models_after(spec: GraphSpec, node_id: str) -> int:
|
||
"""这个节点下游、这一段执行里还要调模型的节点(MODEL_NODES)有几个。沿连线往下走,人工审批节点不往后走:审批之后是
|
||
新的一段,重新计时。分支汇合、环路里的节点只算一次,自己不算;分支上不一定执行的也算上,宁可多留。"""
|
||
nodes = spec.node_map()
|
||
children: dict[str, list[str]] = {}
|
||
for edge in spec.edges:
|
||
children.setdefault(edge.source, []).append(edge.target)
|
||
seen: set[str] = {node_id}
|
||
stack = list(children.get(node_id, []))
|
||
count = 0
|
||
while stack:
|
||
cur = stack.pop()
|
||
node = nodes.get(cur)
|
||
if cur in seen or node is None:
|
||
continue
|
||
seen.add(cur)
|
||
if node.type == NodeType.HUMAN:
|
||
continue
|
||
if node.type in MODEL_NODES:
|
||
count += 1
|
||
stack.extend(children.get(cur, []))
|
||
return count
|
||
|
||
|
||
def _now() -> float:
|
||
return asyncio.get_running_loop().time()
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Allot:
|
||
"""allot() 的答复。start:够不够再开始一项。timeout_s:这一项的时长上限,没收紧时就是传进来的那个值(不限时为
|
||
None)。tightened:按剩余时间收紧过(比配置的短,或者配置的是不限)。seconds:这一段的总时长,不再开始、收紧过的
|
||
时候说明里写;没有上限时为 None。"""
|
||
|
||
start: bool
|
||
timeout_s: float | None
|
||
tightened: bool = False
|
||
seconds: float | None = None
|
||
|
||
|
||
def allot(timeout_s: float | None, *, grace: float, after: int = 0) -> Allot:
|
||
"""开始一项核查之前问一句:够不够,这一项的时长上限收紧到多少。timeout_s 是这一项配置的时长上限(None 不限),
|
||
grace 是到点之后收尾那次调用的宽限(verify_agent.SETTLE_GRACE_S),after 是下游这一段里还要调模型的节点数
|
||
(models_after,按它多留余量)。
|
||
|
||
一项最坏要用「时长上限 + 收尾宽限」。剩下的时间先扣掉给下游的余量、再扣掉收尾宽限,就是这一项查库最多能用的时长:
|
||
比配置的短就收紧到它(取到 0.1 秒,往下取:时间线上写「时长上限 69.7 秒」,不写一串小数)。收紧之后不到
|
||
min(配置的时长上限, 收尾宽限) 就不再开始:收尾宽限是留给一次模型调用的时间,查库的时长比它还短,连一轮都做不完,
|
||
只会白花一次收尾的钱、记成没能核查。配置的时长上限本来就比它短的,按配置的整段算;配置的是不限的,按收尾宽限算。
|
||
没有上限时原样放行。"""
|
||
found = _SEGMENT.get()
|
||
if found is None:
|
||
return Allot(True, timeout_s)
|
||
room = math.floor((found.deadline - _now() - reserve(found.seconds, after) - grace) * 10) / 10
|
||
limit = room if timeout_s is None else min(timeout_s, room)
|
||
floor = grace if timeout_s is None else min(timeout_s, grace)
|
||
if limit < floor:
|
||
return Allot(False, None, seconds=found.seconds)
|
||
return Allot(True, limit, tightened=timeout_s is None or room < timeout_s, seconds=found.seconds)
|
||
|
||
|
||
def relabel(emit: Callable[..., Any], allot: Allot) -> Callable[..., Any]:
|
||
"""交给核查 agent 的 emit。这一项的时长收紧过时,它到点自己发的 verify_limit 写的是收紧后的秒数(「时长上限 52.3
|
||
秒」),和任何设置都对不上,读的人会去找这个数是在哪儿配的、甚至去调大它:改说运行时长上限将至,带上 cause 和这一段
|
||
的总时长。别的事件、别的上限原样转发;没收紧时原样交回 emit。"""
|
||
if not allot.tightened:
|
||
return emit
|
||
|
||
def wrapped(kind: Any, **data: Any) -> Any:
|
||
if str(kind) == EventType.LOG and data.get("code") == "verify_limit" and data.get("limit") == "timeout_s":
|
||
data = {**data, "cause": RUN_DEADLINE, "run_seconds": allot.seconds,
|
||
"message": SQUEEZED_LIMIT.format(seconds=allot.seconds, timeout=allot.timeout_s)}
|
||
return emit(kind, **data)
|
||
|
||
return wrapped
|
||
|
||
|
||
__all__ = ["MODEL_NODES", "RESERVE_CAP", "RESERVE_S", "RESERVE_SHARE", "RUN_DEADLINE", "SHORT_SHARE", "Allot",
|
||
"Segment", "allot", "current", "models_after", "relabel", "reserve", "segment"]
|