业务代码凭什么不能直接调 Agent?——我在律所 AI 项目里做的 Harness 运行时治理

我们最近开发了一个律所 AI 平台,Go 管业务、Python 管 Agent。项目里有一条硬性约束:业务代码禁止直接调 Agent、Skill 或大模型,唯一出口是 Harness 的 RunTurn。这篇文章拆解这套运行时治理到底解决了什么。

一、先把问题说清楚:Demo 和企业级的区别在哪

Demo 阶段,你在 service 里写一行 agent.Chat(msg),能跑就完事。真上生产,立刻会冒出一堆问题:

  • 权限:凭什么这个接口能调这个工具?谁授权?
  • 稳定:Agent 挂了,业务要不要一起挂?
  • 可观测:一次回答错了,是检索、工具、模型还是后处理的问题?
  • 审计:谁在什么时候调了什么,能不能追溯?
  • 安全:日志里不能把手机号、案情明文写进去。

这些问题不靠"多写几个 if",而要靠一层统一的运行时治理。我们管它叫 Harness。

二、唯一出口:所有 AI 调用都从 RunTurn 走

这是最关键的一条规则:Go 业务层不直接调 Agent,唯一入口是 harness.Runtime.RunTurn

go 复制代码
RunTurn(ctx, input)
  ├─ 1. 白名单校验(deny by default)
  ├─ 2. 审计 think 步骤
  ├─ 3. 熔断器 Allow() 放行判断
  ├─ 4. AgentClient.RunTurn 调用 Python Agent
  ├─ 5. 成功:RecordSuccess + 审计 llm_call(带耗时)
  └─ 6. 失败:RecordFailure + 写入失败交接队列 + 返回错误码

核心代码长这样(已精简):

go 复制代码
func (rt *Runtime) RunTurn(ctx context.Context, in RunTurnInput) (*RunTurnOutput, error) {
	start := time.Now()
	if rt.whitelist == nil {
		err := apierr.New(apierr.CodeToolDenied, "白名单未初始化,deny by default")
		rt.writeAudit(ctx, in, "think", nil, nil, false, err.Error(), time.Since(start))
		return nil, err
	}
	rt.writeAudit(ctx, in, "think", map[string]string{"message": in.Message}, nil, true, "", 0)

	if rt.breaker != nil {
		if err := rt.breaker.Allow(); err != nil {
			rt.writeAudit(ctx, in, "circuit_break", in, nil, false, err.Error(), time.Since(start))
			return nil, apierr.New(apierr.CodeAgentServiceError, err.Error())
		}
	}

	resp, err := rt.agentClient.RunTurn(ctx, AgentTurnRequest{TraceID: in.TraceID, RunID: in.RunID, SessionID: in.SessionID, CustomerID: in.CustomerID, Message: in.Message, Channel: in.Channel})
	latency := time.Since(start)

	if err != nil {
		if rt.breaker != nil {
			_ = rt.breaker.RecordFailure()
		}
		if rt.handoff != nil {
			_, _ = rt.handoff.Enqueue(FailureContext{TraceID: in.TraceID, RunID: in.RunID, SessionID: in.SessionID, CustomerID: in.CustomerID, Message: in.Message, Cause: err.Error()})
		}
		apiErr := apierr.New(apierr.CodeAgentServiceError, err.Error())
		rt.writeAudit(ctx, in, "llm_call", in, nil, false, apiErr.Error(), latency)
		return nil, apiErr
	}

	if rt.breaker != nil {
		rt.breaker.RecordSuccess()
	}
	out := &RunTurnOutput{SessionID: resp.SessionID, Reply: resp.Reply, Stage: resp.Stage, Turn: resp.Turn, Intent: resp.Intent, NeedMoreInfo: resp.NeedMoreInfo, Tag: resp.Tag, FollowupID: resp.FollowupID, RAG: resp.RAG}
	rt.writeAudit(ctx, in, "llm_call", in, resp, true, "", latency)
	return out, nil
}

注意几个细节:白名单为 nil 时直接拒绝(默认拒绝);熔断在前、调用在后;审计在调用前后各打一次;失败不是只返回错误,还把它丢进 handoff 队列,后面可以补做或人工接管。

三、默认拒绝:白名单是最小权限的落地

我们用一份 YAML 白名单控制 Agent 能调哪些工具:

go 复制代码
allowed_tools:
  - get_customer_info
  - create_followup_record
  - send_message
  - legal_qa_skill
  - customer_tagging_skill

加载时如果文件缺失或解析失败,直接返回 error,而不是放行。校验逻辑一句话概括:白名单为空或未加载,任何工具都拒绝。

go 复制代码
func (w *Whitelist) Check(toolName string) error {
	if w == nil {
		return &ErrToolDenied{ToolName: toolName}
	}
	if _, ok := w.allowed[toolName]; !ok {
		return &ErrToolDenied{ToolName: toolName}
	}
	return nil
}

这就是"最小权限":不是"默认允许、黑名单排除",而是"默认拒绝、白名单显式授权"。对 AI 应用尤其重要------模型的输出是不确定的,能调的工具越少,出事的面积越小。

四、熔断器:Agent 挂了,不能让业务一起挂

Agent 服务超时、报错、不可用是常态。如果业务每次都死等,一个 Agent 故障就能拖垮整个后端。这里是一个极简的三态熔断器:closed / open / half-open。

  • closed:正常放行
  • 连续失败达到阈值(默认 3 次)→ open
  • open:在 openTimeout(默认 30 秒)内直接拒绝,快速失败
  • open 超时后进入 half-open,放一个探测请求;成功回到 closed,失败继续 open
go 复制代码
func (c *CircuitBreaker) Allow() error {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.state == CircuitOpen {
		if time.Since(c.openedAt) < c.options.OpenTimeout {
			return ErrCircuitOpen
		}
		c.state = CircuitHalfOpen
		c.probe = true
	}
	if c.state == CircuitHalfOpen && !c.probe {
		return ErrCircuitOpen
	}
	if c.state == CircuitHalfOpen {
		c.probe = false
	}
	return nil
}

快速失败 + 半开放探测,是熔断器的两个关键动作。业务侧拿到的是明确错误码,而不是无限等待。

五、审计要脱敏,还要不阻塞主链路

审计很容易做成"又慢又脏"。这里做了两件事:

  1. 写之前先脱敏:手机号 13812345678138****5678,超长内容截断到 2048 字符,避免把完整案情明文塞进日志。
  2. 异步写:审计落库放在 goroutine 里,带 recover 兜底,审计挂了不能拖垮主链路。
go 复制代码
func maskPII(s string) string {
	return phonePattern.ReplaceAllStringFunc(s, func(m string) string {
		if len(m) != 11 {
			return m
		}
		return m[:3] + "****" + m[7:]
	})
}

调用方只关心 TraceID、SessionID、步骤、成功与否、耗时。一次回答为什么错,可以顺着 TraceID 从头看到尾。

六、失败要能"交接",而不只是报错

失败时除了 RecordFailure,还把上下文塞进 HandoffQueue:

go 复制代码
if err != nil {
    _ = rt.breaker.RecordFailure()
    _, _ = rt.handoff.Enqueue(FailureContext{
        TraceID: in.TraceID, RunID: in.RunID,
        SessionID: in.SessionID, CustomerID: in.CustomerID,
        Message: in.Message, Cause: err.Error(),
    })
    return nil, apierr.New(apierr.CodeAgentServiceError, err.Error())
}

这一步的价值在于:模型失败不是终点,它是一段可以被重新处理、被人工接管、被复盘的任务。Demo 阶段没人做这件事,企业级必须做。

七、这套东西,面试怎么讲

如果面试官问"你做过什么工程化的事情",别只说"我用了 RAG",可以说:

我做项目时,业务层不直接调 Agent,而是加了一层 Harness 运行时治理:用白名单做默认拒绝的权限控制、用三态熔断器做降级、用异步审计加脱敏做可观测、用失败交接队列做兜底。这样 Agent 不稳定或模型出错时,业务不会被拖垮,问题也能定位。

这段话背后是真实代码、真实踩坑,比背八股有说服力得多。

八、欢迎交流

这个项目还落地了 MCP 工具总线、Skill 三段式和 207 条回归评测,完整实现和在线演示我放在评论区和我的个人主页,欢迎交流:wangzhongyang.com/

相关推荐
l1258651 小时前
# RAG上线评估指标体系:六大核心指标与压测实战全解析
数据库·人工智能·python·mysql·langchain·milvus
Python 实战手记1 小时前
微信公众号跨主体迁移变更审核流程实操解析:场景条件、公证材料规范、避坑要点与校验脚本实现
人工智能
william_yangshun1 小时前
【AI Agent 实战】cindy 中文版:开箱即用的开源 AI 代理上手指南
人工智能·开源
我命由我123451 小时前
人脸识别 - 人脸识别选帧
java·人工智能·python·算法·安全·java-ee·人脸识别
Ai-_Man1 小时前
豆包收藏夹能批量导出吗?从底层逻辑拆解「AI导出鸭」如何解构这一技术难题
人工智能·ai·小程序
速易达网络1 小时前
宇树机器人具身智能研发的全技术栈
人工智能
ReleaseU1 小时前
PTC 模式深度实战:测试驱动开发的 Agent 化
人工智能·大模型
m0_638079622 小时前
2026年AI论文写作辅助工具技术对比与使用观察
大数据·人工智能
商业数据派2 小时前
日赚近1亿的网易,这个季度栽在了拼多多身上
大数据·人工智能