斗皇境·Harness 治理:让日志、安全与超时不再散落在每个 Tool 里
每个 Tool 自己打日志、自己判权限,Agent 一复杂就没人说得清它能干什么。
text
[读者] 正把 ADK Java 数字人从单人 Demo 推向多人协作环境的工程师
[痛点] 日志、ACL、超时、取消、异常处理散落在每个 Tool 与 Controller 里
[现在读] 数字人已经能记住用户、能调工具,但还没有统一的执行约束层
[读完] 能用 Callbacks 与 Plugin 搭出 Runtime 治理层,并落地 RuntimeGuardCallbacks
text
[旧方案] 每个 Tool 自己写日志、自己判权限、自己 try/catch
|
v
[新需求] 一个数字人挂多个工具、跑多步流程,需要统一约束
|
v
[冲突] 横切关注点写进业务代码,规则重复、口径不一、无法审计
|
v
[后果] 安全靠 Prompt 兜底,超时靠运气,排障靠猜
我是老李,在一家做制造业数字化交付的公司带数字人团队。前面几境,我们把数字人从"只会调一次 LLM"一路推到"能开口、有工具、有 Runner、有 Context 与 State、能记用户、能读项目文件"。到斗皇境这一步,它已经同时挂着仓库检索、工单查询、周报生成、部署触发四类工具,一个会话能连续干十几分钟。
但它的骨架很脆:每加一个 Tool,就要在新 Tool 里重抄一遍日志格式、权限判断、异常捕获。第五个工具接进来时,团队里已经存在三种日志格式、两种权限口径,还有一个 Tool 连 try/catch 都忘了写。
真正的转折是一次线上事故。数字人卡在一次工具调用上,会话状态一直是 pending,用户以为它在思考,其实它在等一个永远不会回来的响应。事后翻日志,这次调用连"开始"都没记,因为那个 Tool 的日志写在成功分支里。
比"它不干活"更麻烦的是:我说不清它到底能干什么。项目 A 的会话里,模型凭 instruction 里一句"不要访问其他项目",这次确实没越界------那下一次呢?
横切的东西,到底该放在 Tool 里,还是该放在 Runtime 里?
如果放进 Runtime,是用 Callbacks 起步,还是直接上 Plugin?
选错的代价有多大------只是一个 bug,还是一整类不可审计的行为?
01、故事
现场是周二凌晨两点。客户的交付数字人正在跑一条"扫描本周仓库变更、关联工单、生成周报"的链路。任务是让数字人在一次会话里自己决定先查哪一步、再查哪一步,而不是由后端写死调用顺序。
V1 的写法很直白:四个 Tool,各自管各自。
java
public class QueryRepositoryTool {
public Map<String, Object> run(Map<String, Object> args) {
log.info("query repository: " + args); // 格式一
String projectId = (String) args.get("projectId");
if (!currentProject.equals(projectId)) {
return Map.of("error", "forbidden"); // 口径一:返回错误对象
}
try {
return repoClient.query(projectId);
} catch (Exception e) {
log.error("failed", e);
return Map.of("error", e.getMessage());
}
}
}
另一个 Tool 长这样:
java
public class DeployServiceTool {
public Map<String, Object> run(Map<String, Object> args) {
if (!allowed(args)) {
throw new SecurityException("denied"); // 口径二:抛异常
}
// 这里有日志吗?没有。作者忘了。
return deployClient.deploy(args);
}
}
这段代码当时"能跑",数字人确实把周报生成出来了。问题是它把三类横切关注点焊进了业务类:日志、安全、失败处理。业务 Tool 的数量和横切规则的份数是乘法关系,不是加法关系。
凌晨卡住的是 DeployServiceTool。它调用的下游没有返回,也没有超时。模型那边还在等工具结果,Runtime 层也没有任何"这次执行跑了多久"的概念。
第二天早上我做的第一件事不是改代码,而是问:如果明天加第十个 Tool,我要把同样的日志、同样的权限、同样的超时再抄第十遍吗?
答案显然是不。于是斗皇境真正的目标变成了:把"执行约束"从 Tool 里搬出来,放进一个所有 Agent、所有 Model、所有 Tool 都绕不过去的地方。
一器承四事
一关锁千门
一诺须留痕
一线定死生
02、问题
旧方案是"谁执行谁负责":Tool 负责自己的日志、自己的安全、自己的失败处理。它在三个 Tool 以内是合理的,因为重复的成本还不明显。第四个、第五个工具接进来之后,它同时失效在三件事上。
第一,规则重复导致口径漂移。日志从 log.info 漂到 System.out.println,权限从"返回错误对象"漂到"抛 SecurityException"。同一次 invocation 里并存三种失败语义,上层没有任何统一判断依据。
第二,规则遗漏无法被发现。忘写日志不报错,忘写权限不报错,只有在被利用时才报错。这类缺陷也很难在代码评审里拦住,因为每个 Tool 单看都是对的。
第三,Runtime 层对执行毫无感知。它只知道工具返回了没有,不知道跑了多久、调了几次、有没有越界。于是超时、取消、次数限制这些只属于执行层的能力,没有任何地方可以挂。
业务影响立刻显现:当客户问"你们这个数字人上周有没有访问过 XX 项目的数据",我们的回答是"我翻一下四个 Tool 的日志看看"------而其中一个 Tool 根本没记。
技术表现是可观测、可审计、可中断三件事同时缺位。
要让它变成一次可交付的升级,完成标准必须写得能验证:
- 任意一次 invocation 的生命周期(agent 开始与结束、model 开始与结束、tool 开始与结束)都产生结构化记录,并能被同一个 invocationId 串起来;
- 越权工具调用不进入 Tool 实现体,被拦截时产生带 code 的审计记录,而不是靠 Prompt 提示;
- 单次模型调用有确定性的时间上限,超时后执行被中断并记录 code=TIMEOUT;
- 单次 invocation 的模型调用次数、工具调用次数、输出长度都有上限,超限时拒绝继续,而不是无限重试;
- 以上能力不修改任何 Tool 的业务实现,只新增治理组件与一次接线;
- 有可运行的测试结果。
第 5 条是这次升级里最贵、也最值钱的一条。
一规不两写
一漏必留痕
一界须可数
一停要有据
03、原理
这一章要理解的核心,是"执行路径上的插入点"。
数字人一次执行不是一条直线,而是一串有明确边界的阶段:一次 invocation 进入 Agent,Agent 调用 Model,Model 可能要求调用某个 Tool,Tool 返回结果,Model 再决定下一步,直到产生最终回复。每个阶段的开始与结束,都是一次可插入的位置。
ADK Java 把这批插入点暴露成 Callbacks。在官方仓库的 core/src/main/java/com/google/adk/agents/Callbacks.java 里,它们按阶段分组:before/after Agent、before/after Model、before/after Tool,以及对应的错误回调。回调接收的上下文对象(CallbackContext,以及它下面的 InvocationContext)就是"这一次执行"的账本------它知道这是哪个 invocation、哪个用户、哪个会话、当前状态是什么。
第一个反直觉判断:横切能力越重要,越不能写在被横切的地方。
安全的直觉是"谁干活谁负责",所以在 Tool 里判权限看起来最贴近数据。但权限的作用域不是单次工具调用,而是整个会话与整个项目边界。写在 Tool 里,规则就被切成 N 份,每份都可能不一致;写在 Runtime 的插入点上,规则只有一份。
第二个反直觉判断:把安全规则写进 Prompt,不是安全,是概率。
指令注入、上下文漂移、模型版本变化,都会让这条规则时灵时不灵。概率约束适合表达"偏好",确定性约束必须落在执行路径上------也就是工具调用真正发出之前的那零点几毫秒。
第三个反直觉判断,也是这一章最想强调的:加护栏不是限制数字人,而是让它可以被放心地放开。
没有护栏的时候,你不敢给它挂危险工具,不敢让它连续跑十几步,因为一旦跑飞你收不回来。有了统一护栏,你反而敢开放更多能力------因为你知道最坏情况是可界定、可中断、可追溯的。
这里还有一个必须分清的东西:Tool 与 Harness 是两种职责。
Tool 负责业务能力------能查仓库、能建工单、能发周报。Harness 负责执行约束------决定这次调用允不允许、跑多久、算不算数、出错算谁的。两者不该互相渗透。工具越"聪明",Harness 越应该"无知":Harness 不需要知道"周报"是什么,只需要知道"这次调用越界了"。
ADK Java 里还有一个更靠外的接入方式:Plugin。它把同一批回调提升到 Runner 层,可以在应用维度统一生效,而不必逐个 Agent 注册。是否应该把治理层放到 Plugin 上,取决于你要治理的是一个 Agent 还是一个应用;Plugin 的具体回调集合与签名,需按当前官方文档核验。
一插定一路
一概率不可托
一界分两责
一放因有绳
04、架构
text
[输入] 用户消息 / 系统事件
|
v
[模块] Runner -> RuntimeGuardCallbacks(治理层) -> LlmAgent / Model / Tool
|
v
[数据/状态] CallbackContext / InvocationContext / Session State / 每次 invocation 的护栏计数
|
v
[处理] 前置校验(ACL、限额) -> 执行 -> 后置记录(审计、指标) -> 失败分类与包装
|
v
[输出] Event 流 + 审计日志 + 指标 + 确定性拒绝
总线上的每一段,边界都要说清楚。
治理层不持有业务语义:它不知道"工单""周报""部署"分别意味着什么,只认识调用者、目标、时间、次数、输出长度。
治理层不替业务做重试决策:它只给失败分类,重试与否由失败策略决定,而失败策略的执行次数计入 invocation 级护栏。
治理层必须覆盖每一个可能阻塞的阶段:只在工具侧加超时,就会在模型侧被挂住;只在模型侧加超时,就会在工具侧被挂住。
收益:规则只有一份,审计只有一条链,执行可中断,安全边界是可断言的布尔值而不是概率。
代价:所有 Agent 都要接入治理层(一次性接线成本);护栏阈值需要按业务调参,调得太紧会误杀正常长任务,调得太松等于没设;多了一层间接,排障时要多看一层调用栈。
适用条件:多 Tool、多 Agent、多人协作、涉及写操作,或有合规与审计要求。反过来,如果数字人只有一两个只读工具、单用户使用,这层治理属于过度设计。
一层拦众险
一账记全程
一价换可审
一界须自觉
05、实战一次
环境与版本:
- JDK 21
- Maven 3.9+
- Google ADK Java 1.11.0
- 分支:
chapter/07-harness-guard
依赖(版本对齐 CHANGELOG,Maven 坐标以仓库 README 为准):
xml
<dependency>
<groupId>com.google.adk</groupId>
<artifactId>google-adk</artifactId>
<version>1.11.0</version>
</dependency>
核心实现分两个部分:治理层本体(纯 Java,不依赖 ADK 类型,可单独测试),以及接线(把治理层挂到 Agent 生命周期上)。
先看治理层的契约部分。
java
package com.example.dh.harness;
import java.time.Duration;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public final class RuntimeGuardCallbacks {
private static final Logger audit = LoggerFactory.getLogger("dh.audit");
private static final ExecutorService TIMEOUT_POOL = Executors.newVirtualThreadPerTaskExecutor();
public record GuardPolicy(int maxModelCalls, int maxToolCalls, int maxOutputChars, Duration stageTimeout) {
public static final GuardPolicy DEFAULT = new GuardPolicy(8, 12, 8_000, Duration.ofSeconds(30));
public GuardPolicy {
if (maxModelCalls <= 0 || maxToolCalls <= 0 || maxOutputChars <= 0) {
throw new IllegalArgumentException("guard policy values must be positive");
}
Objects.requireNonNull(stageTimeout, "stageTimeout");
}
}
public sealed interface GuardDecision permits GuardDecision.Allow, GuardDecision.Deny {
record Allow() implements GuardDecision {}
record Deny(String code, String reason) implements GuardDecision {}
}
public interface MetricsSink {
void increment(String name);
static MetricsSink noop() {
return name -> { };
}
}
private static final class Ledger {
final AtomicInteger modelCalls = new AtomicInteger();
final AtomicInteger toolCalls = new AtomicInteger();
final long startNanos = System.nanoTime();
long elapsedMillis() {
return (System.nanoTime() - startNanos) / 1_000_000L;
}
}
private final GuardPolicy policy;
private final ProjectAcl acl;
private final MetricsSink metrics;
private final Map<String, Ledger> ledgers = new ConcurrentHashMap<>();
public RuntimeGuardCallbacks(GuardPolicy policy, ProjectAcl acl, MetricsSink metrics) {
this.policy = Objects.requireNonNull(policy);
this.acl = Objects.requireNonNull(acl);
this.metrics = Objects.requireNonNull(metrics);
}
再看回调实现部分。三个阶段的"开始"都先记录,再执行------这是让"没有结束记录"本身成为一个可告警信号的关键。
java
public GuardDecision beforeAgent(String invocationId, String userId, String sessionProjectId) {
ledgers.put(invocationId, new Ledger());
audit.info("guard.agent.start invocation={} user={} project={}",
invocationId, userId, sessionProjectId);
return new GuardDecision.Allow();
}
public void afterAgent(String invocationId, String outcome) {
Ledger ledger = ledgers.remove(invocationId);
audit.info("guard.agent.end invocation={} outcome={} modelCalls={} toolCalls={} elapsedMs={}",
invocationId, outcome,
ledger == null ? -1 : ledger.modelCalls.get(),
ledger == null ? -1 : ledger.toolCalls.get(),
ledger == null ? -1L : ledger.elapsedMillis());
}
public GuardDecision beforeModel(String invocationId, String modelName) {
if (ledger(invocationId).modelCalls.incrementAndGet() > policy.maxModelCalls()) {
metrics.increment("dh.guard.model.blocked");
return new GuardDecision.Deny("MODEL_BUDGET_EXCEEDED",
"model calls over " + policy.maxModelCalls() + " in one invocation");
}
audit.info("guard.model.start invocation={} model={}", invocationId, modelName);
return new GuardDecision.Allow();
}
public void afterModel(String invocationId, int outputChars) {
audit.info("guard.model.end invocation={} outputChars={}", invocationId, outputChars);
if (outputChars > policy.maxOutputChars()) {
metrics.increment("dh.guard.model.output_too_long");
}
}
public GuardDecision beforeTool(String invocationId, String toolName,
Map<String, Object> args, String sessionProjectId) {
if (ledger(invocationId).toolCalls.incrementAndGet() > policy.maxToolCalls()) {
metrics.increment("dh.guard.tool.blocked");
return new GuardDecision.Deny("TOOL_BUDGET_EXCEEDED",
"tool calls over " + policy.maxToolCalls() + " in one invocation");
}
Object declared = args.get("projectId");
String targetProject = declared == null ? sessionProjectId : String.valueOf(declared);
ProjectAcl.Verdict verdict = acl.check(sessionProjectId, toolName, targetProject);
return switch (verdict) {
case ProjectAcl.Verdict.Allow ignored -> new GuardDecision.Allow();
case ProjectAcl.Verdict.Denied denied -> {
metrics.increment("dh.guard.acl.denied");
audit.warn("guard.acl.denied invocation={} tool={} from={} to={} code={}",
invocationId, toolName, sessionProjectId, targetProject, denied.code());
yield new GuardDecision.Deny(denied.code(), denied.reason());
}
case ProjectAcl.Verdict.NeedsApproval needs -> {
metrics.increment("dh.guard.acl.approval");
yield new GuardDecision.Deny("NEEDS_APPROVAL", needs.reason());
}
};
}
public void afterTool(String invocationId, String toolName, long elapsedMillis) {
audit.info("guard.tool.end invocation={} tool={} elapsedMs={}",
invocationId, toolName, elapsedMillis);
}
最后是超时包装与失败出口。所有可能阻塞的调用都走这里,超时即取消,并抛出带 code 的失败。
java
public <T> T guarded(String invocationId, String stage, Supplier<T> body) {
Future<T> future = TIMEOUT_POOL.submit(body::get);
long start = System.nanoTime();
try {
T value = future.get(policy.stageTimeout().toMillis(), TimeUnit.MILLISECONDS);
audit.info("guard.stage.end invocation={} stage={} elapsedMs={}",
invocationId, stage, (System.nanoTime() - start) / 1_000_000L);
return value;
} catch (TimeoutException e) {
future.cancel(true);
metrics.increment("dh.guard.timeout");
throw fail(invocationId, stage, "TIMEOUT", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
future.cancel(true);
throw fail(invocationId, stage, "CANCELLED", e);
} catch (ExecutionException e) {
throw fail(invocationId, stage, "STAGE_ERROR", e.getCause() == null ? e : e.getCause());
}
}
private RuntimeException fail(String invocationId, String stage, String code, Throwable cause) {
audit.error("guard.stage.fail invocation={} stage={} code={}", invocationId, stage, code, cause);
return new IllegalStateException("[" + code + "] " + stage + " failed in " + invocationId, cause);
}
private Ledger ledger(String invocationId) {
return ledgers.computeIfAbsent(invocationId, key -> new Ledger());
}
}
ACL 的 V1 实现刻意做得很窄,只做租户隔离------第 06 章会证明它不够。
java
package com.example.dh.harness;
public interface ProjectAcl {
sealed interface Verdict permits Verdict.Allow, Verdict.Denied, Verdict.NeedsApproval {
record Allow() implements Verdict {}
record Denied(String code, String reason) implements Verdict {}
record NeedsApproval(String reason) implements Verdict {}
}
Verdict check(String sessionProjectId, String toolName, String targetProjectId);
/** V1:只做租户隔离,高危工具尚未特殊处理。 */
static ProjectAcl sameProjectOnly() {
return (sessionProjectId, toolName, targetProjectId) -> {
if (targetProjectId == null || targetProjectId.isBlank()) {
return new Verdict.Denied("ACL_NO_TARGET", "tool call declared no target project");
}
if (!targetProjectId.equals(sessionProjectId)) {
return new Verdict.Denied("ACL_CROSS_PROJECT", "cross-project access denied");
}
return new Verdict.Allow();
};
}
}
接线只有一次。下面表达的是"哪个回调接哪段治理逻辑",回调接口的入参与返回值契约以 Callbacks.java 为准;如果你的版本要求双参形式,把方法引用换成 lambda、在 lambda 里取出 invocationId 即可,治理层入参不用变。
java
LlmAgent guardedAgent = LlmAgent.builder()
.name("project-assistant")
.model("gemini-2.0-flash")
.description("交付项目数字人")
.instruction("你是交付项目数字人,只处理当前会话绑定的项目。")
.tools(ProjectTools.all())
.beforeAgentCallback(guardAdapter::beforeAgent)
.afterAgentCallback(guardAdapter::afterAgent)
.beforeModelCallback(guardAdapter::beforeModel)
.afterModelCallback(guardAdapter::afterModel)
.beforeToolCallback(guardAdapter::beforeTool)
.afterToolCallback(guardAdapter::afterTool)
.build();
启动与请求。Runner 与 Content 的构建方式沿用官方 quickstart 写法,需按当前官方文档核验:
java
Runner runner = Runner.builder()
.agent(guardedAgent)
.appName("dh-ch07")
.sessionService(new InMemorySessionService())
.build();
Flowable<Event> events = runner.runAsync(userId, sessionId,
Content.fromParts(Part.fromText("帮我看看 proj-beta 的仓库变更")));
Disposable subscription = events
.timeout(60, TimeUnit.SECONDS)
.subscribe(
event -> System.out.println("event = " + event),
error -> System.out.println("run failed: " + error.getMessage()),
() -> System.out.println("run completed"));
未在当前环境实测,以下为预期结果。
示例输出(审计日志形态):
log
guard.agent.start invocation=inv-7f3a user=u-102 project=proj-alpha
guard.model.start invocation=inv-7f3a model=gemini-2.0-flash
guard.model.end invocation=inv-7f3a outputChars=182
guard.acl.denied invocation=inv-7f3a tool=query_repository from=proj-alpha to=proj-beta code=ACL_CROSS_PROJECT
guard.agent.end invocation=inv-7f3a outcome=completed modelCalls=2 toolCalls=1 elapsedMs=4180
这次越权调用没有进入 Tool 实现体,也没有产生任何下游请求。V1 跑通了,但留下三个问题:ACL 只按项目相等判断,高危工具的语义缺失;超时只加在工具与模型调用之外,接线时容易漏掉某一阶段;阈值硬编码在常量里,换业务要改代码。
一例先跑通
一码不省略
一验须留据
一境始可攀
06、排查
链一:同项目内的高危工具被直接执行。
现象:测试环境发生一次误部署。调用方是同一个项目里的普通会话,没有任何越权参数,审计日志里 ACL 结果是 allow,全程没有任何阻断记录。
怀疑:模型乱调工具;或者工具描述写得不够"吓人",模型没意识到风险。
检查:把该 invocationId 下的全部审计记录拉出来,逐条看 beforeTool 的判定输入与输出。
证据:sessionProjectId 与 targetProjectId 都是 proj-alpha,V1 的 sameProjectOnly 直接返回 Allow。也就是说,V1 只验证了"租户隔离",没有验证"这次操作该不该由这个会话发起"。
根因:边界不完整。把"租户隔离"当成了"安全",导致同租户内的高危操作完全没有二次确认。更深一层的根因是:工具的风险等级属于执行约束,却被留在了业务语义里。
错误尝试: 我第一版的修复是把部署工具的描述改得更严厉------在 description 里写"该工具会重启生产服务,请谨慎使用",同时在 instruction 里补一句"执行部署前必须先向用户确认"。为什么错:工具描述与 instruction 都是给模型看的输入,不是给执行路径看的约束;它们对正常请求只能提高概率,对注入请求几乎无效。更糟的是,它不可审计------事后你只能看到"模型没遵守提示",看不到"系统拦住了什么"。
修复:把工具风险等级提升为治理层的一等公民。高危工具即使同项目,也返回 NEEDS_APPROVAL,在人类批准之前不执行;拒绝动作发生在工具实现体之前,因此不会有任何副作用先发出去。
链二:模型调用挂住,整次会话没有任何进展。
现象:一次 invocation 在模型阶段停滞 90 秒,客户端只能重连,会话状态长期处于 pending。
怀疑:模型服务端慢;或者网络抖动。
检查:按 invocationId 检索日志,看每个阶段的开始与结束是否配对。
证据:guard.model.start 有记录,guard.model.end 没有;同时 guard.stage.* 系列只有工具侧出现过,模型侧从未出现。
根因:超时加在了错误的层级。V1 只把 guarded(...) 用在了工具调用上,模型调用没有走这层包装,所以阻塞发生在模型阶段时,护栏完全不存在。护栏必须覆盖每一个可能阻塞的阶段,而不是挑一个阶段加。
修复:把 guarded(...) 抽成所有阶段通用的执行包装,模型调用与工具调用一律经过它;同时把"开始"记录前置到执行之前,让"只有开始没有结束"成为一个可告警的信号。
一疑先看据
一据定一因
一改要看界
一验再收兵
07、优化
从链一的证据看,根因是"边界不完整",修改落在 ACL 的判定维度上:从单一的项目相等,扩展为项目边界加工具风险等级。
java
/** V2:项目边界 + 工具风险等级。 */
static ProjectAcl withRiskLevels(Set<String> highRiskTools) {
return (sessionProjectId, toolName, targetProjectId) -> {
if (targetProjectId == null || targetProjectId.isBlank()) {
return new Verdict.Denied("ACL_NO_TARGET", "tool call declared no target project");
}
if (!targetProjectId.equals(sessionProjectId)) {
return new Verdict.Denied("ACL_CROSS_PROJECT", "cross-project access denied");
}
if (highRiskTools.contains(toolName)) {
return new Verdict.NeedsApproval("high risk tool " + toolName + " requires human approval");
}
return new Verdict.Allow();
};
}
从链二的证据看,根因是"护栏覆盖不全、失败无证据",修改落在执行包装与记录顺序上:所有阶段统一走 guarded(...),"开始"记录前置。
再往上抽一层,把失败分类固定下来,避免每个 Tool 自己决定要不要重试。
java
public enum FailurePolicy {
FAIL_FAST, // 配置错误、ACL 拒绝、参数非法:直接失败,不重试
RETRY_ONCE, // 下游瞬时错误:允许一次重试,重试同样走治理层
DEGRADE // 可降级能力(如周报润色):返回降级结果并记指标
}
原因:把"要不要重试"从 Tool 实现里拿出来。工具只负责说"我失败了,这是 code",治理层决定这次失败属于哪一类。这样重试次数天然受 invocation 级护栏约束,不会因为两个 Tool 各自重试三次而把总量放大到六次。这也是护栏和重试能共存的前提:重试必须有账。
新行为:一次 invocation 的完整轨迹变成可按 invocationId 检索的一条链;越界调用在执行之前被拒绝;高危工具即使同项目也要经过批准;超时调用会中断并留下 code=TIMEOUT;重试总量受 maxModelCalls 与 maxToolCalls 双重约束。
验证方案(不是验证结果,本文未实测):
- 构造目标项目与会话项目不一致的工具调用,期望在进入 Tool 实现体之前被拒绝,审计记录含 ACL_CROSS_PROJECT,且下游无任何请求;
- 用一个固定 sleep 超过时限的假工具,期望在时限内抛出带 TIMEOUT 的异常,会话随后可继续接收下一次 invocation;
- 构造连续请求工具超过上限的场景,期望在被拒绝后整次执行有序结束,而不是无限循环;
- 对高危工具构造同项目调用,期望得到 NEEDS_APPROVAL,且在批准前工具实现体不被进入。
四条测试的实际结果请在本地跑通后记录,不要写成已经发生的数据。
一根因一改
一策分三类
一重不叠重
一证后收束
08、演进
text
[同一输入] 用户消息:帮我看看本周仓库变更并生成周报
|
+--[V1] 每个 Tool 自己打日志 / 判权限 / 捕获异常
| 代价:口径漂移、遗漏无法发现、超时与取消没有落点
|
+--[V2] RuntimeGuardCallbacks 统一治理
| 代价:需要接入治理层、护栏阈值需要按业务调参
|
[Trade-off]
得到:确定性边界、可审计轨迹、可中断执行、规则可复用
失去:Tool 里"随手写一句判断"的灵活性,多一层间接调用
适用边界:多 Tool、多 Agent、多人协作,涉及写操作或有合规审计要求
正确性:V1 的边界依赖 Prompt 与各处散落判断,是概率性的;V2 的边界落在工具调用之前的确定性插入点上,是布尔性的。
稳定性:V1 下一次下游抖动就能让会话无限挂起;V2 有明确时间上限与取消路径。
复杂度:V1 单看每个 Tool 都简单,系统层面的复杂度隐性且随工具数增长;V2 多了一个治理组件与一次接线,但规则只有一份,工具数增加时治理成本不增。
成本:V2 有一次性的接线成本,以及每次调用多一次内存判断的运行时开销。
适用范围:如果数字人只有一两个只读工具、单用户使用,V1 可以接受,V2 属于过度设计。一旦涉及写操作、多租户、多人协作或合规审计,V2 就不是可选项。
遗留问题:人工确认只做到了"拒绝并给出 NEEDS_APPROVAL",还没有"暂停、批准、原地续跑"的闭环;治理层目前挂在 Agent 维度,是否上移到应用维度的 Plugin 也未定;指标只定义了接口,还没有接到真实后端;护栏阈值仍是硬编码常量。
一演看两代
一取必有一舍
一界量力而设
一缺留待后补
09、洞见
9.1 Tool 负责能力,Harness 负责约束
Tool 是业务能力的载体,Harness 是执行约束的载体。这两件事混在一起时,代码在所有维度上都会退化:安全性依赖每个 Tool 的自觉,日志格式随作者变化,失败语义无法统一。把它们分开之后,Tool 可以只写三行业务代码,而约束在 Runtime 层对所有 Tool 生效。判断标准很简单:如果一段逻辑在第二个 Tool 里又被写了一遍,它就不属于 Tool。
9.2 安全不是 Prompt 里的一行字
反直觉判断:把规则写得更强硬------全大写、加感叹号、在 instruction 里重复三遍------不会让它变成确定性约束,只会让上下文更长、注意力更分散。
Prompt 是给模型看的,执行路径上的判断是给系统看的。前者影响概率,后者决定结果。凡是"不能出错"的约束,都必须落在后者。这不是对模型能力的不信任,而是对系统边界的正确归位。
9.3 护栏让数字人更自由
反直觉判断:护栏看起来是在"限制"数字人,实际上它是你愿意开放更多能力的前提。
没有超时和限额,你永远不敢让它连续跑十几步,也不敢给它挂部署工具;有了它们,最坏情况从"不可知"变成"可界定的数"------最多 8 次模型调用、最多 12 次工具调用、每个阶段最多 30 秒。这个数就是你能对业务做出的承诺。
9.4 治理层越"无知"越可靠
治理层不应该知道"周报"是什么,也不应该知道"工单"的业务规则。它只认三件事:这次调用是谁发起的、目标是什么、有没有超界。一旦治理层开始理解业务,它就会和业务一起变更,然后失去"横切"的意义,也无法在第二个数字人项目里复用。
一职须分清
一硬不在字
一束方得放
一无胜有知
10、系统落地
这个数字人项目在前一境结束时已有的东西是:能开口的 Agent、有身份和工具边界的数字人、Event 驱动的 Runtime、可追踪的 InvocationContext 与 State、能续聊能记用户的 Session 与 Memory。
本篇新增的是治理层本身:RuntimeGuardCallbacks,加上 ProjectAcl、MetricsSink、GuardPolicy 与 FailurePolicy,以及一次接线。它不改动任何 Tool 的业务实现。
现在这个数字