阶段四:HarnessAgent 企业级治理与性能可观测平台

这是 LingNova 机器人智能管家项目的第四篇阶段性记录。前三个阶段,先后完成了 Spring AI 基础对话、AgentScope Harness 工程化改造,以及多 Agent、RAG 和工具可靠性。到了阶段四,要解决的问题是:谁可以调用它?一次请求花了多少 Token?高危工具能不能直接执行?出了问题以后,能不能查清楚到底卡在哪一步?

一、阶段四到底在解决什么问题

阶段三结束时,LingNova 已经能调度多个子 Agent、检索知识库、调用 MCP 和业务工具。从功能角度看,它已经挺完整了。但如果真的把它交给多个企业客户使用,问题很快就会暴露出来。

1. 引入 tenantId 做多租户管理

userId 解决的是“这是谁”,sessionId 解决的是“这是哪一次对话”,但它们都不能表达“这个用户属于哪家企业”。

举个简单的例子:A 公司和 B 公司都可能有一个 admin 用户。如果系统只按 userId=admin 存状态,两家公司的会话、配额和工具权限就可能互相影响。引入 tenantId 后,系统的隔离维度变成:

tenantId(企业)
  └─ userId(企业内用户)
       └─ sessionId(某次会话)

租户也不只是一个数据字段。它还是治理策略的边界:不同企业可以有不同的模型额度、API 速率、允许模型和工具名单。

2. Agent 调用工具权限控制

查询产品和删除数据的风险显然不同。普通用户、管理员和不同租户也不应该拥有完全相同的工具集合。

这一阶段把工具权限统一成三种结果:

  • ALLOW:允许直接执行;
  • ASK:风险较高,先生成审批单;
  • DENY:明确拒绝。

三态决策比简单的 true/false 多了一层“等待人工判断”的空间,这也是人在回路(Human in the Loop,简称 HITL)的核心价值。

3. 大模型调用预算控制

普通接口通常按 QPS 限流,但 Agent 一次请求里可能产生多轮推理、多次工具调用和多次模型调用。只限制 HTTP 请求次数,并不能控制实际成本。

所以阶段四同时做了两层限制:

  • API 限流:控制请求速度和突发流量;
  • Token 配额:控制每个租户、用户每月可以消耗的模型额度。

4. 添加Agent调用观测面板

一次 Agent 请求可能经过主 Agent、推理、模型、工具和子 Agent。总耗时 8 秒,到底是模型用了 7 秒,还是某个工具重试了三次?只看普通文本日志很难回答。

因此这一阶段补了两种不同的观测数据:

  • Metrics(指标):回答“最近一小时成功率多少、P95 延迟多少、Token 用了多少”;
  • Trace(链路):回答“这一条请求具体经过了哪些步骤,每一步用了多久”。

5. 对话记录持久化

AgentState 是给模型推理使用的上下文。为了控制上下文窗口,它会被裁剪、压缩,并不适合作为用户可查询的完整聊天记录。

因此阶段四把两种存储拆开:Redis 保存 Agent 推理状态,MySQL 保存完整会话归档。用户切换会话时查询的是 MySQL 历史;再次调用模型时,使用的是 AgentScope 管理过的上下文,而不是把数据库中的所有历史消息重新塞进 Prompt。

二、阶段四完成后的整体链路

把这些组件放在一起,一次 HarnessAgent 请求大致会经过下面这条链路:

HTTP 请求
  -> TenantContextFilter
       ├─ 建立 tenant / user / session / role 身份上下文
       └─ Redis 两级令牌桶限流
  -> SessionController / SessionService
       ├─ MySQL 写入完整聊天归档
       └─ HarnessGateway 加载 Redis AgentState
  -> HarnessAgent
       -> HarnessGovernanceMiddleware
            ├─ onAgent:输入护栏、根 Span、审计
            ├─ onReasoning:推理 Span、生命周期指标
            ├─ onActing:工具权限预检
            └─ onModelCall:模型策略、Token 预扣与结算
  -> ToolExecutionWrapper
       ├─ ALLOW / ASK / DENY
       ├─ HITL 审批
       ├─ 超时、重试、熔断、缓存
       └─ 工具 Trace、Metrics、审计

可观测面板
Metrics -> Actuator -> Prometheus -> Grafana
Trace   -> OTLP/gRPC -> Jaeger

这里最关键的改动,是治理不再只停留在 Controller 或 Web Filter。HTTP Filter 只能看到“一次接口请求”,看不到 Agent 内部什么时候开始推理、什么时候准备调用工具、什么时候真正请求模型。要治理这些内部动作,必须进入 HarnessAgent 自己的生命周期。

三、把治理真正接进 HarnessAgent Middleware

最初项目里已经有 Spring AI Advisor,它适合处理 ChatClient 的输入改写、日志和指标。但 HarnessAgent 有自己的运行链路。如果只在外层继续堆 Advisor,会出现一个问题:外面知道请求开始了,却不知道里面发生了什么。

因此我实现了 HarnessGovernanceMiddleware,并在构建 HarnessAgent 时注册:

HarnessAgent.Builder builder = HarnessAgent.builder()
        // 省略模型、记忆、工作区等配置
        .middleware(harnessGovernanceMiddleware)
        .middleware(subagentResultLoopMiddleware);

Middleware 可以拦截四个关键生命周期:

@Override
public Flux<AgentEvent> onAgent(
        Agent agent,
        RuntimeContext context,
        AgentInput input,
        Function<AgentInput, Flux<AgentEvent>> next) {
    TenantContext.Identity identity = resolveIdentity(context);
    validateUserMessages(input.msgs(), agent.getName(), identity);
    return governed(agent, context, identity,
            "agent.run", "agent", null,
            Map.of("messageCount", size(input.msgs())), () -> next.apply(input));
}

onAgent 管整次执行,onReasoning 管一轮推理,onActing 管工具行动,onModelCall 管模型调用。(onSystemPrompt在每次组装 system prompt 时触发,暂时没用到)它们最终都进入 governed,统一创建 Span、记录开始审计,并处理成功、失败和取消。

这段代码里保留了一个很短的 Lambda:() -> next.apply(input)。它并不是为了追求函数式写法,而是为了延迟执行下一个 Middleware。Reactor 的流只有被订阅时才真正运行,如果提前调用,下游逻辑的执行时机会发生变化。

响应式代码还有一个容易误解的地方:

return result
        .doOnError(callbacks::fail)
        .doOnComplete(callbacks::complete)
        .doFinally(callbacks::finish);

这三个方法不是三选一:

  • 正常结束时执行 complete,随后一定会执行 finish
  • 发生异常时执行 fail,随后也会执行 finish
  • 客户端主动断开时,通常只在 finish 中收到 CANCEL

因此我用 AtomicBoolean 保证成功、失败、取消只记录一个最终状态,而 doFinally 专门负责恢复父 Span、释放资源并结束当前 Span。这个细节如果处理错,很容易出现同一条审计写两次,或者 Jaeger 中 Span 一直不结束。

四、多租户身份、会话和完整聊天归档

1. 身份只在入口建立一次

TenantContextFilter 从可信网关透传的请求头中取得租户、用户、会话和角色,建立一个 TenantContext

try (TenantContext.Scope ignored = TenantContext.open(
        value(request, "X-Tenant-Id", "public"),
        value(request, "X-User-Id", "anonymous"),
        value(request, "X-Session-Id", request.getSession().getId()),
        roles)) {
    filterChain.doFilter(request, response);
}

这里使用 try-with-resources,不只是代码整洁。Servlet 线程会被线程池复用,如果 ThreadLocal 中的租户身份没有清理,下一个请求可能读到上一个请求的身份,这属于很严重的数据串租户问题。

Controller 不再接收客户端随意传入的 userId。业务层统一从当前身份上下文取值,再用 tenantId + userId + sessionId 查询会话。

2. AgentState 也要包含租户维度

AgentScope 原生状态接口使用 userId + sessionId 定位状态。为了不侵入框架实现,我把 tenantId 和 userId 分别做 URL-safe Base64 编码,再组合成内部 ownerId:

private String stateOwnerId(TenantContext.Identity identity) {
    Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
    String tenant = encoder.encodeToString(
            identity.tenantId().getBytes(StandardCharsets.UTF_8));
    String user = encoder.encodeToString(
            identity.userId().getBytes(StandardCharsets.UTF_8));
    return tenant + ':' + user;
}

这样无需修改 AgentScope 的 AgentStateStore 接口,也能实现 tenant、user、session 三级隔离。

3. Redis 状态和 MySQL 归档各做各的事

SessionService 现在同时协调两套存储:

  • Redis AgentState:模型继续推理所需的短期上下文和压缩状态;
  • MySQL 会话归档:会话真实创建时间、消息数量、Token 数量和完整聊天记录。

流式响应尤其需要小心。客户端可能正常接收完,也可能中途断开,还可能因为模型异常而结束。现在使用 StreamConversation 把三种终态封装起来:

return createAgentStream(conversation)
        .doOnNext(conversation::append)
        .doOnComplete(conversation::complete)
        .doOnError(conversation::fail)
        .doFinally(conversation::finish);

正常结束保存为 SUCCESS,异常保存为 FAILED,客户端取消保存为 CANCELLED。即使只生成了一半回答,也能在归档中看到当时到底发生了什么。

另外新增了会话历史分页接口。用户从三个 session 中切换到另一个 session 时,可以查询 MySQL 中的完整历史;模型是否使用这些历史,则由 AgentState 和上下文压缩策略决定。把“展示历史”和“模型上下文”拆开后,数据语义清晰了很多。

五、权限、审计和人在回路

1. 权限不是一张写死的角色表

PermissionGuard 的判断顺序是:先看租户工具策略,再看角色限制,最后判断是否属于高风险工具。

public Decision decide(String toolName) {
    String normalized = normalize(toolName);
    TenantContext.Identity identity = TenantContext.current();
    if (!policyService.isToolAllowed(identity.tenantId(), normalized)) {
        return Decision.DENY;
    }
    if (identity.hasRole("ADMIN")) {
        return HIGH_RISK_TOOLS.contains(normalized)
                ? Decision.ASK : Decision.ALLOW;
    }
    if (ADMIN_TOOLS.contains(normalized)) {
        return Decision.DENY;
    }
    return HIGH_RISK_TOOLS.contains(normalized)
            ? Decision.ASK : Decision.ALLOW;
}

这里黑名单优先于白名单,白名单为空代表不额外限制。所有工具名统一转为小写,避免通过大小写差异绕过规则。

2. ASK 不是弹个提示框,而是一次状态转换

工具命中 ASK 后,ToolExecutionWrapper 不会继续执行,而是创建一个有效期 15 分钟的审批单,并抛出包含 approvalId 的业务异常。审批状态为:

PENDING -> APPROVED
PENDING -> REJECTED
PENDING -> EXPIRED

只有同租户管理员可以处理,重复审批保持幂等。批准、拒绝和过期都会留下审计记录。

当前版本完成的是“阻断执行 + 审批状态管理”闭环,审批数据暂存在内存中。它适合本地演示和验证治理流程,但如果要多实例部署,还要把审批单放到数据库或 Redis,并通过任务状态机恢复原工具调用,而不是简单地重新发起请求。

3. 审计和普通日志不是一回事

普通日志主要给开发排错,审计回答的是“谁在什么时间对什么资源做了什么,结果如何”。阶段四记录了 tenantId、userId、sessionId、requestId、输入摘要、输出摘要和结果。

写入前还会做两件事:

  • 对 password、secret、token、api-key 等字段脱敏;
  • 单字段超过 2000 字符时截断,避免模型长输出撑爆日志。

当前审计同样是线程安全的内存实现,接口已经与存储解耦。生产化时应该迁移到只追加的数据库表、日志平台或对象存储,并设置留存和访问策略。

六、租户级策略、Redis 限流和模型配额

阶段四最初只有全局配置,后来我发现这还不够。企业 A 可能允许每月 100 万 Token,企业 B 可能需要 500 万;有的租户允许调用某个高价模型,有的只能用基础模型。因此增加了租户治理策略表和管理接口。

系统先查数据库中的租户策略,没有配置时再回退到 application.yml

lingnova:
  governance:
    rate-limit:
      capacity: 60
      refill-tokens: 60
      refill-period: 1m
    model-quota:
      monthly-tokens: 1000000
      estimated-tokens-per-call: 2048

capacity=60 表示令牌桶最多存 60 个令牌;refill-tokens=60refill-period=1m 表示每分钟补充 60 个。请求消费一个令牌,没有令牌时返回 HTTP 429。

限流使用 Redis Lua 脚本同时检查两个桶:

  • 租户总桶,防止通过创建大量用户绕过限制;
  • tenant + user + session 会话桶,限制单个会话的突发请求。

把“补充、检查、扣减”写在同一个 Lua 脚本中,是为了保证多实例并发时的原子性。如果两个桶中任意一个余额不足,本次请求不会误扣另一个桶。

模型 Token 配额采用“预扣 + 结算”:

  1. 调用模型前按 estimated-tokens-per-call 预扣;
  2. 收到 ModelCallEndEvent 后读取真实 Token;
  3. 实际用量多于预扣就补扣,少于预扣就退还;
  4. 调用失败或取消时释放全部预扣。

预扣很重要。假如只在模型返回后记账,多个并发请求可能同时看到“余额充足”,最终一起突破月度上限。Redis Key 带有年月、tenantId 和 userId,并在下个月自动过期。

七、评估

阶段四保留并扩展了评估体系,准备了 50 条机器人领域用例,覆盖产品推荐、故障排查、技术概念和资讯查询。

AgentEvaluator 会逐条执行 RAG 和回答流程,然后组合两类结果:

  • Judge 评分:正确性、完整性、幻觉和引用情况;
  • RAG 指标:Recall@5、忠实度和回答相关性。

Recall@5 很直观:人工标注的正确资料,有没有出现在前五条检索结果里。忠实度关注回答是否有检索资料支持,相关性关注有没有答非所问。

项目提供本地 Judge 和 Java 版轻量 RAGAS 等价指标,不配置外部模型也能生成稳定的 JSON、Markdown 报告并与基线比较。这里需要保持诚实:本地规则评分主要用于回归和流程验证,并不等于完整版 RAGAS 或一个真正强大的 LLM Judge。生产评估还需要更严格的人工标注、外部 Judge 交叉验证和统计分析。

八、本地可观测平台是怎么把数据“自动”显示出来的

用户调用 /api/.../chat
        │
        ▼
HarnessAgent 开始执行
        │
        ▼
HarnessGovernanceMiddleware 拦截生命周期
        │
        ├── OpenTelemetry 创建 Span
        │       └── 通过 OTLP 发送给 Jaeger
        │
        └── AgentMetrics 调用 Micrometer
                └── 指标进入 MeterRegistry
                        └── Actuator 暴露 /actuator/prometheus
                                └── Prometheus 每15秒抓取
                                        └── Grafana 查询并展示

这部分是阶段四最容易被一堆名词绕晕的地方。实际拆开看,只有两条数据通道。

1. Metrics:Micrometer -> Actuator -> Prometheus -> Grafana

Micrometer 可以理解成 Java 应用里的统一指标接口。业务代码通过 MeterRegistry 记录计数和耗时:

registry.counter(
        "lingnova.agent.lifecycle.calls",
        "operation", operation,
        "agent", agent,
        "outcome", outcome)
        .increment();

registry.timer(
        "lingnova.agent.lifecycle.latency",
        "operation", operation,
        "agent", agent)
        .record(elapsedNanos, TimeUnit.NANOSECONDS);

Spring Boot Actuator 检测到 Prometheus Registry 后,会自动把这些 Meter 转换成 /actuator/prometheus 能读取的文本格式。Prometheus 每 15 秒访问一次这个地址,把指标保存为时间序列;Grafana 再用 PromQL 查询 Prometheus,并画成折线图、成功率和 P95 延迟。

P95 的意思是:把请求耗时从小到大排列,95% 的请求都不超过这个值。平均值容易被少数慢请求“稀释”,P95 更适合观察大部分用户的实际等待体验。

项目预置的 Grafana 看板可以查看:

  • Agent 生命周期调用量、成功率、平均延迟和 P95 延迟;
  • 工具调用成功率、吞吐量和 P95 延迟;
  • 模型 Token 总量和消耗速率;
  • 子 Agent 委派次数。

如图所示:
LingNova Agent 企业治理
Java 服务运行监控

2. Trace:OpenTelemetry -> OTLP -> Jaeger

OpenTelemetry 负责定义和生成 Span。Span 可以理解为一次操作的计时片段,例如一次 agent.run、一轮 agent.reasoning 或一次 agent.model.call。多个有父子关系的 Span 组成一条 Trace。

Middleware 中创建 Span 后,会写入这些属性:

SpanBuilder builder = tracer.spanBuilder(operation)
        .setAttribute("agent.name", agent.getName())
        .setAttribute("agent.id", agent.getAgentId())
        .setAttribute("agent.kind", kind)
        .setAttribute("tenant.id", identity.tenantId())
        .setAttribute("user.id", identity.userId())
        .setAttribute("session.id", identity.sessionId());

Span 结束后,BatchSpanProcessor 会异步批量发送到配置的 OTLP/gRPC 地址。在 Docker 网络中配置为:

OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4317

这里必须使用 Docker Compose 服务名 jaeger,不能在 AI 容器里写 localhost。容器中的 localhost 指向它自己,不是 Jaeger 容器。

Jaeger 收到数据后负责存储和展示整条调用链。打开 http://localhost:16686,选择 lingnova-ai-service,就能看到 agent.run -> agent.reasoning -> agent.model.call 的父子关系和各自耗时。

如图所示:
Jaeger首页

Trace调用链展示

3. Grafana 和 Jaeger 的前端页面从哪里来

这些页面不是项目自己用 Vue 或 React 写的。Grafana、Prometheus 和 Jaeger 本身就是带 Web UI 的开源服务,通过 Docker 镜像启动即可使用。

项目额外做的是:

  • 给 Prometheus配置 AI 服务的抓取地址;
  • 给 Grafana 预置 Prometheus 数据源和 Dashboard JSON;
  • 给 AI 服务配置 Jaeger 的 OTLP 地址;
  • 在 Docker Compose 中把三个组件接入同一网络并暴露端口。

所以应用代码只负责“生产数据”,Prometheus 和 Jaeger 负责“存储、查询”,Grafana 和 Jaeger UI 负责“展示”。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐