Files
AgentLab/backend/app/engine/guards.py
T
ThearchyHelios 03c18c4d78 界面文案整改:口语改书面、术语统一、不再露出实现细节;修掉几处说错的提示;新增文案检查防回潮
一、文案(前端约 1600 处,后端传到界面的约 880 处)
- 术语统一:工作流层面用「运行」(继续运行、重新运行、试运行),节点和工具层面用「执行」,不再用「跑」;
  总时长 / 执行时长 / 等待审批;服务端;助手;Agent;token;工作流;固定版本;模型接入;系统提示;批准 / 驳回;受管级别
- 口语改书面:取不到、没取回来、读不懂、连不上、多半、照样、兜底、揉成这类说法改成「无法 / 未能 / 失败 / 请」;去掉拟人写法
- 不再露出实现细节:界面上不写 checkpoint、同一拍、哈希(凭证处除外),不写改版历史(「以前…」「老数据」);
  报错、校验、门禁、自动修复里裸写的配置键名和枚举值换成界面上的中文标签,由新的 backend/app/engine/labels.py 统一提供;
  发布弹窗里结论句的选项不再是 off / require_citation
- 同一件事只留一种说法:生成阶段文案、出具档位说明、画布锁定提示各合成一份
- 去掉界面上的工业场景示例(MES、ERP、「张工」「王工」)和真实产品名,换成通用示例

二、说错的提示(都有测试证明)
- 数据库测连接失败时的专用提示以前从不出现(按报错文字全等比较,永远比不上):改为按错误类型判断,不再拼出重复的前缀
- 「设置 → 供应商」改指「设置 → 模型接入」;「在设置里建数据源」改指「数据」页
- 缺少 API Key、模型接入已停用的报错不再夹英文,写明去哪里填
- 两处原因说错(「这种引用在后续版本支持」「这一版还不支持」)改成真实原因
- 节点说明里不准确的说法改正:代码节点在 microVM 或系统沙箱中执行;报告撰写、口径卡的说明不再说过头
- 导出的证据 CSV 里两列同名,第三列改为「片段编号」

三、前后端对齐
- 前端按后端原文识别日志、报错的地方,新旧两种写法都认得(历史运行里存的是旧文字),逐条补了新旧原文的核对
- 后端交回模型的原指令放进单独的 for_model 字段,前端在解析响应时去掉,不会显示到界面上

四、检查
- 检查脚本里按原文匹配的断言改成新文案;改完会变成永远成立的反向断言逐条修正,并做了变异验证
- 新增 check-copy(静态扫描前端界面字符串和后端上界面的文案):再出现「跑」「后端」「多半」「令牌」、
  界面上的 Copilot、裸写的配置键名、半角标点等就报错;确需保留的写进 scripts/copy-allow.json 并注明理由
2026-09-30 08:05:15 +08:00

247 lines
13 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
"""agent 节点的护栏:取代以前那个固定的「最多 12 步」。
固定步数挡的是四件事,每件都有更贴切的挡法:
- 循环不收敛(反复查同一张表、在两个做法之间来回)→ 重复调用直接复用上次结果,
连续几步拿不到新信息就收尾;
- 花费越滚越大(每一步都重发前面的全部对话)→ 按令牌 / 金额记预算,可以设成不限;
- 上下文会满(满了是调用直接报错,查到的全丢)→ 接近窗口时先压缩早期工具结果,再不够就收尾;
- 真正失控的兜底 → 步数只留一个很高的上限(默认 100,设置里可改)。
规矩:
- 上限在运行发起时快照下来(RunContext.agent_limits),恢复、接着跑都用这一份。审批恢复时
节点整个重放,上限要是变了,循环停在不同的位置,checkpoint 里的任务结果就对错号了;
- 快照为 None 是升级前发起的运行:一律走旧逻辑(默认 12 步、没有这些护栏)。它们可能正停在
某个审批上,多一次复用、少一次调用,重放的位置同样会错开;
- 这里的每个判断只取决于重放时照样拿得到的东西(模型回复里的用量、工具结果),不读时钟。
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any
from langchain_core.messages import BaseMessage, ToolMessage
#: 以前画布给新节点写死的步数。几乎都是画布自动写进去的,不是谁特意选的,按「没填」处理
LEGACY_DEFAULT_STEPS = 12
#: 连续几步拿不到新信息就收尾
STALL_STEPS = 3
#: 目录里查不到窗口的模型,按这个保守值算
FALLBACK_CONTEXT = 128_000
#: 用到窗口的这个比例,开始压缩早期工具结果
COMPRESS_AT = 0.6
#: 压缩之后还到这个比例,就收尾:留出收尾轮自己要的那部分
SETTLE_AT = 0.85
#: 压缩时原样保留最近几条工具结果
KEEP_RECENT = 3
#: 超过这个字数的旧工具结果才压缩,压缩后留开头这么多字
COMPRESS_OVER, COMPRESS_HEAD = 1200, 400
DEFAULT_LIMITS: dict[str, Any] = {
# 兜底步数;实际还受 settings.max_agent_steps(环境变量)封顶
"agent_max_steps": 100,
# 每个 agent 节点的令牌预算(输入 + 输出)。None 表示不限
"agent_budget_tokens": 2_000_000,
# 金额预算(美元)。目录里没有价格的模型估不出金额,只能靠令牌预算。None 表示不限
"agent_budget_usd": None,
}
def _positive(value: Any) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if number > 0 else None
def run_limits(run_defaults: dict[str, Any], hard_cap: int) -> dict[str, Any]:
"""发起运行时的快照。设置里存坏了的值按缺省算;预算写 null / 0 / 空都表示不限。"""
steps = _positive(run_defaults.get("agent_max_steps"))
tokens = run_defaults.get("agent_budget_tokens", DEFAULT_LIMITS["agent_budget_tokens"])
usd = run_defaults.get("agent_budget_usd", DEFAULT_LIMITS["agent_budget_usd"])
return {
"max_steps": min(int(steps or DEFAULT_LIMITS["agent_max_steps"]), hard_cap),
"budget_tokens": int(_positive(tokens)) if _positive(tokens) else None,
"budget_usd": _positive(usd),
}
@dataclass
class Limits:
max_steps: int
budget_tokens: int | None
budget_usd: float | None
def node_limits(config: dict[str, Any], snapshot: dict[str, Any], hard_cap: int) -> Limits:
"""节点上写了的覆盖快照。步数恰好是 12 的当作没写(见 LEGACY_DEFAULT_STEPS)。"""
raw_steps = _positive(config.get("max_steps"))
if raw_steps is not None and int(raw_steps) == LEGACY_DEFAULT_STEPS:
raw_steps = None
steps = int(raw_steps) if raw_steps else int(snapshot.get("max_steps") or DEFAULT_LIMITS["agent_max_steps"])
tokens = _positive(config.get("budget_tokens"))
usd = _positive(config.get("budget_usd"))
return Limits(
max_steps=max(1, min(steps, hard_cap)),
budget_tokens=int(tokens) if tokens else snapshot.get("budget_tokens"),
budget_usd=usd if usd else snapshot.get("budget_usd"),
)
def call_key(name: str, args: dict[str, Any]) -> str:
return json.dumps({"tool": name, "args": args}, ensure_ascii=False, sort_keys=True, default=str)
#: 收尾的原因 → (给模型的一句话, 给人看的提示)
REASONS: dict[str, tuple[str, str]] = {
"steps": ("步数上限用完了({max_steps} 步)",
"Agent 已用完 {max_steps} 步上限。如需完整结果,请调大节点的「最大步数」或设置中的默认步数;"
"如果大部分步数用于逐张表查询结构,可在提示词中指明要查的表。"),
"stall": ("连续 {stall} 步没有拿到新信息(重复的调用,或者调用都失败了)",
"Agent 连续 {stall} 步没有获得新信息,已根据已查到的内容收尾。可能是提示词中的目标无法查到,"
"或工具参数持续有误,请检查 Agent 最后几次的工具调用。"),
"budget_tokens": ("这个节点的 token 预算用完了(已用 {used_tokens:,} / 上限 {budget_tokens:,})",
"Agent 已用完 token 预算({budget_tokens:,}),已根据已查到的内容收尾。如需完整结果,"
"请调大节点的「token 预算」或设置中的默认预算,也可在设置中改为不限。"),
"budget_usd": ("这个节点的金额预算用完了(已用 ${used_usd:.4f} / 上限 ${budget_usd:.4f})",
"Agent 已用完金额预算(${budget_usd:.2f}),已根据已查到的内容收尾。如需完整结果,"
"请调大节点的「金额预算(美元)」或设置中的默认预算,也可在设置中改为不限。"),
"context": ("对话已经接近模型的上下文上限(约 {used_context:,} / {window:,} token)",
"Agent 的对话已接近模型上下文上限,压缩早期工具结果后仍不够,已根据已查到的内容收尾。"
"请缩小每次查询的范围(如在 SQL 中加 LIMIT、只选需要的列),或换用上下文窗口更大的模型。"),
}
@dataclass
class Guard:
limits: Limits
window: int
#: 成功执行过的调用 → (第几步, 结果)
seen: dict[str, tuple[int, str]] = field(default_factory=dict)
stalled: int = 0
reminded: set[str] = field(default_factory=set)
last_input: int = 0
compressed: bool = False
facts: dict[str, Any] = field(default_factory=dict)
# ------------------------------------------------------------ 重复调用
def repeat(self, name: str, args: dict[str, Any]) -> str | None:
"""同一个工具、同样的参数已经成功跑过:返回要喂回去的那句话,不再执行。"""
hit = self.seen.get(call_key(name, args))
if hit is None:
return None
step, content = hit
return (f"这个调用(同一个工具、同样的参数)第 {step + 1} 步已经执行过,没有再执行。上次的结果:\n"
f"{content}\n\n不要重复调用:基于已有结果继续,或者换一个能拿到新信息的查询,或者给出结论。")
def record(self, step: int, name: str, args: dict[str, Any], ok: bool, content: str) -> None:
if ok:
self.seen.setdefault(call_key(name, args), (step, content))
def step_done(self, progressed: bool) -> None:
self.stalled = 0 if progressed else self.stalled + 1
# ------------------------------------------------------------ 用量与上下文
def observe(self, response: BaseMessage, messages: list[BaseMessage]) -> None:
"""记下这一次模型调用实际用了多大的上下文。供应商没报用量就按字数粗估。"""
meta = getattr(response, "usage_metadata", None) or {}
reported = int(meta.get("input_tokens") or 0)
if reported:
self.last_input = reported
else:
chars = sum(len(m.content) if isinstance(m.content, str) else len(json.dumps(m.content, default=str))
for m in messages)
self.last_input = chars // 2
def compress(self, messages: list[BaseMessage]) -> int:
"""上下文用到 COMPRESS_AT 以上时,把早期的长工具结果换成开头一段。返回压缩了几条。"""
if self.last_input < self.window * COMPRESS_AT:
return 0
tool_positions = [i for i, m in enumerate(messages) if isinstance(m, ToolMessage)]
squeezed = 0
for i in tool_positions[:-KEEP_RECENT] if KEEP_RECENT else tool_positions:
m = messages[i]
text = m.content if isinstance(m.content, str) else json.dumps(m.content, ensure_ascii=False, default=str)
if len(text) <= COMPRESS_OVER or text.endswith(_SQUEEZED_TAIL):
continue
messages[i] = ToolMessage(
content=f"{text[:COMPRESS_HEAD]}\n…(早期的工具结果已压缩,原文 {len(text)} 字。"
f"需要其中的细节就重新查一个更小的范围){_SQUEEZED_TAIL}",
tool_call_id=m.tool_call_id,
)
squeezed += 1
if squeezed:
self.compressed = True
return squeezed
def stop_reason(self, usage: dict[str, Any], *, context: bool = True) -> str | None:
"""该收尾了吗。步数用完由循环自己管,这里管另外三种。context=False 时不按上下文判。"""
if self.stalled >= STALL_STEPS:
return "stall"
used_tokens = int(usage.get("input_tokens") or 0) + int(usage.get("output_tokens") or 0)
if self.limits.budget_tokens and used_tokens >= self.limits.budget_tokens:
return "budget_tokens"
if self.limits.budget_usd and float(usage.get("cost_usd") or 0) >= self.limits.budget_usd:
return "budget_usd"
if context and self.last_input >= self.window * SETTLE_AT:
return "context"
return None
def reminder(self, step: int, usage: dict[str, Any]) -> str | None:
"""临近上限时提醒模型一次(每种只提醒一次)。返回要追加的一句话。"""
notes = []
left = self.limits.max_steps - (step + 1)
if self.limits.max_steps > 5 and left <= 3 and "steps" not in self.reminded:
self.reminded.add("steps")
notes.append(f"还剩 {left} 步可以调用工具")
used_tokens = int(usage.get("input_tokens") or 0) + int(usage.get("output_tokens") or 0)
if self.limits.budget_tokens and used_tokens >= self.limits.budget_tokens * 0.8 \
and "budget" not in self.reminded:
self.reminded.add("budget")
notes.append(f"token 预算已用 {used_tokens * 100 // self.limits.budget_tokens}%")
if self.limits.budget_usd and float(usage.get("cost_usd") or 0) >= self.limits.budget_usd * 0.8 \
and "budget_usd" not in self.reminded:
self.reminded.add("budget_usd")
notes.append("金额预算已用到八成")
if self.stalled == STALL_STEPS - 1 and "stall" not in self.reminded:
self.reminded.add("stall")
notes.append(f"已经连续 {self.stalled} 步没有拿到新信息")
if not notes:
return None
return f"(系统提醒:{';'.join(notes)}。优先补齐最关键的信息,然后给出结论。)"
def describe(self, reason: str, usage: dict[str, Any]) -> tuple[str, str]:
"""(给模型的收尾理由, 给人看的提示)"""
values = {
"max_steps": self.limits.max_steps, "stall": STALL_STEPS,
"used_tokens": int(usage.get("input_tokens") or 0) + int(usage.get("output_tokens") or 0),
"budget_tokens": self.limits.budget_tokens or 0,
"used_usd": float(usage.get("cost_usd") or 0), "budget_usd": self.limits.budget_usd or 0.0,
"used_context": self.last_input, "window": self.window,
}
to_model, to_user = REASONS[reason]
return to_model.format(**values), to_user.format(**values)
_SQUEEZED_TAIL = "⁣" # 不可见的记号:压缩过的不再压第二遍
def legacy_hint(max_steps: int, settled: bool) -> str:
"""升级前(没有护栏快照)的运行用的说法:只有步数这一种收尾原因。"""
if settled:
return (f"Agent 已用完 {max_steps} 步上限,以上结论基于已查到的部分。"
"如需完整结果,请调大节点的「最大步数」;如果大部分步数用于逐张表查询结构,"
"可在提示词中指明要查的表。")
return (f"Agent 已用完 {max_steps} 步上限,仍未给出结论。"
"请调大节点的「最大步数」;如果大部分步数用于逐张表查询结构,可在提示词中指明要查的表。")
#: 结果里 limited 字段的说法,下游校验失败时拿来说明根源
LIMITED_LABEL = {
"steps": "步数用完", "stall": "连续多步没有获得新信息", "budget_tokens": "token 预算用完",
"budget_usd": "金额预算用完", "context": "对话接近上下文上限",
}