第四章 从0搭建企业级HarnessAgent项目-HarnessAgent 企业级治理与性能可观测平台
阶段四: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=60、refill-period=1m 表示每分钟补充 60 个。请求消费一个令牌,没有令牌时返回 HTTP 429。
限流使用 Redis Lua 脚本同时检查两个桶:
- 租户总桶,防止通过创建大量用户绕过限制;
- tenant + user + session 会话桶,限制单个会话的突发请求。
把“补充、检查、扣减”写在同一个 Lua 脚本中,是为了保证多实例并发时的原子性。如果两个桶中任意一个余额不足,本次请求不会误扣另一个桶。
模型 Token 配额采用“预扣 + 结算”:
- 调用模型前按
estimated-tokens-per-call预扣; - 收到
ModelCallEndEvent后读取真实 Token; - 实际用量多于预扣就补扣,少于预扣就退还;
- 调用失败或取消时释放全部预扣。
预扣很重要。假如只在模型返回后记账,多个并发请求可能同时看到“余额充足”,最终一起突破月度上限。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 委派次数。
如图所示:


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 的父子关系和各自耗时。
如图所示:


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


所有评论(0)