我们最近开发了一个律所 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
}
快速失败 + 半开放探测,是熔断器的两个关键动作。业务侧拿到的是明确错误码,而不是无限等待。
五、审计要脱敏,还要不阻塞主链路
审计很容易做成"又慢又脏"。这里做了两件事:
- 写之前先脱敏:手机号
13812345678→138****5678,超长内容截断到 2048 字符,避免把完整案情明文塞进日志。 - 异步写:审计落库放在 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/