面到 harness 架构就卡住的人,多半是做过但没这么叫过

面试里被问"harness 架构讲一下",很多人当场停在那里。这篇把它拆成六层:上下文、编排、工具、模型、状态、观测。每层给一句"这层管什么"和一个具体的工程判断,其中五层配了能编译的 Go 代码,模型层故意不给代码。看完你大概会发现,自己项目里那些散在各处的兜底代码,本来就叫这个名字;真正区分人的不是能背出六层,是能说出这六层在自己的代码里分别落在哪个文件。

面试里有一类卡顿很典型。前面聊得都挺顺,讲工具调用、讲重试、讲怎么把长文档切块,都对。然后面试官问一句:

"你们这个 harness 架构是怎么分的?"

对面停三秒,说:"这块......主要是用框架搭的。"

这个停顿我想说清楚:它一般不代表没做过。它代表做过,但从来没把那些代码当成一个"东西"来命名。你写过上下文裁剪、写过工具注册、写过失败降级、写过重试的幂等判断、写过链路日志,这些散在五六个文件里的东西,合起来就是 harness。面试官问的不是你有没有,是你知不知道它叫什么、各层边界在哪。

先把定义钉住

一句话:harness 是模型之外的运行时。

模型本身只有输入和输出。它不会自己读文件,不会自己调接口,不会记住上一轮说了什么,也不会知道自己刚才那次调用失败了。这些能力全部来自外面那层代码。那层代码就是 harness。

这里有个反常识的判断,值得先说:**同一个模型,harness 写得糙还是写得细,线上表现的差距比换模型大得多。**大家都在纠结选哪个模型、上下文窗口多大、跑分多少,但真正决定一个 Agent 能不能上生产的,是外面这层壳管不管得住。模型选错了顶多笨一点,harness 写错了会直接出事故:重复扣款、越权查数据、把用户的下一次提问接到上一次的会话里。

下面按六层走,每层两样东西:这层管什么、一个具体到能落地的判断。其中五层配了最小代码,gofmt 和 go vet 过的,可以直接拿走改;模型层故意不配代码。

六层总览

层 管什么 一个具体判断
上下文 什么进 prompt、什么时候截断、预算怎么分 工具返回超过阈值要先摘要再入 prompt,不能原样塞
编排 谁决定下一步、串行还是并行 规划归模型,执行归代码,模型只能选允许的动作
工具 工具怎么注册、怎么鉴权、怎么限流 权限声明写在代码里,不写在提示词里
模型 多模型怎么切、失败怎么降级 走 OpenAI 兼容协议,换模型等于改配置
状态 会话与任务状态存哪、怎么恢复 检查点加幂等键,重试不能重复产生副作用
观测 出了事怎么定位 一次调用要能串起来:请求 ID 到计划,再到每次工具调用

一条主线判断,我建议你在面试里一定讲出来:副作用必须收口,权限不能交给模型。

模型可以建议"我要查一下征信",但真正发起调用的必须是工具层那一层代码。因为只有代码能做鉴权、能做审计、能做限流、能在超时时中断。你把权限写进提示词,等于把权限交给了模型的随机性。

为什么是六层?依据只有一条:**每两层之间的失败模式不同,就得切开。**上下文层坏了是"模型看不见",编排层是"模型乱走",工具层是"越权或重复副作用",模型层是"格式崩或降级没生效",状态层是"重试重复执行",观测层是"能复现但定位不了"。记失败模式比记名字有用。

第一层:上下文层

它管的是"模型这一次能看见什么"。听起来简单,实际上是事故最多的一层。

我见过最典型的写法是这样:工具返回一段 8K token 的 JSON,直接拼到消息列表里。第一轮没问题,第三轮上下文就满了,然后开始出现两种症状:模型开始忘记最初的系统设定,以及每次调用的延迟和成本一起往上跳。

**判断:工具返回结果超过阈值,先摘要,再入 prompt。**摘要不是压缩,是抽取:你要的是几个字段,就只留那几个字段,其余的在工具层就丢掉,不要指望模型帮你忽略。

下面这段是裁剪逻辑。它的取舍在于两点:system 段先占位、而且不参与打分排序,因为它被挤掉后面全乱;其余段落按业务打分排序,装不下就整段跳过,绝不切半句,切半句等于给模型一段残句,比不给更糟。

还有个必须写进代码的分支:**system 段自己也装不下的时候怎么办。**跳过最容易写,但等于静默丢掉系统设定,线上表现是模型突然开始聊别的,日志里一行错都没有。所以这种情况返回错误,让上层决定扩预算还是砍 system。

go 复制代码
package ctxwin

import (
	"errors"
	"sort"
)

var (
	ErrSystemOverBudget = errors.New("system prompt exceeds context budget")
	ErrBadBudget        = errors.New("context budget must be positive")
)

// Item 是上下文里的一段:system 提示、工具返回、历史轮次都算。
type Item struct {
	Kind  string // "system" / "tool" / "history"
	Text  string
	Score float64 // 越大越该保留,由业务侧给分
}

func size(it Item) int { return len([]rune(it.Text)) }

// Fit 按预算裁剪上下文:system 段先占位,且不参与打分排序。
// system 装不下时返回错误交给上层,不许静默丢弃。
// 其余段落按 Score 降序填,装不下就整段跳过,不切半句。
func Fit(items []Item, budget int) ([]Item, error) {
	if budget <= 0 {
		return nil, ErrBadBudget
	}
	keep := make([]Item, 0, len(items))
	used := 0
	rest := make([]Item, 0, len(items))
	for _, it := range items {
		if it.Kind == "system" {
			if used+size(it) > budget {
				return nil, ErrSystemOverBudget
			}
			keep = append(keep, it)
			used += size(it)
			continue
		}
		rest = append(rest, it)
	}
	sort.SliceStable(rest, func(i, j int) bool { return rest[i].Score > rest[j].Score })
	for _, it := range rest {
		if used+size(it) > budget {
			continue
		}
		keep = append(keep, it)
		used += size(it)
	}
	return keep, nil
}

这段代码的局限也要说清楚,不然面试官一追就露:按分数排序会打乱时序,而对话是有时序的。所以生产上我是两段式:最近 N 轮原样保留,不参与打分(时序本身是信息),更早的历史才走摘要加打分。你要是被问到"你怎么保证模型记得住刚才说的话",答案就在这里。

预算分配有个经验比例:系统提示加工具描述三成,最近三轮对话三成,工具返回三成,剩一成留给模型输出。这个比例是调出来的,不是教科书数字。调的时候别盯"有没有超",盯"超的时候被挤掉的是谁":挤掉工具返回,模型就编答案;挤掉系统提示,模型就开始聊别的。两种都像"模型不行",其实都是上下文层的事。

摘要用阈值触发加异步,别在请求路径上同步做,否则一次请求变两次模型调用。摘要按文档 ID 缓存,同一文档只摘一次。

第二层:编排层

它管的是"下一步谁说了算"。

这里有一个很多人写错的分工:让模型在循环里自己决定下一步,代码只负责执行。这样代码很短,demo 跑得很漂亮,上线就开始出事。原因是模型没有全局视野,它只看得到当前上下文,不知道第三步会撞上第五步的约束,也不知道某个动作会消耗 30 秒。

**判断:规划归模型,执行归代码。**模型负责输出"想做什么",代码负责判断"能不能做、按什么顺序做、失败了怎么办"。

下面这段是收敛逻辑。注意 Validate 对未知动作的处理是拒绝并回传,不是"尽力执行"。这一点很多人会选错,因为拒绝会让 demo 成功率变低,但正是这个选择把控制面留在了代码里。

go 复制代码
package orch

// Action 是可执行动作的枚举。模型只能从这里面选,选不出别的。
type Action string

const (
	ActSearchProduct Action = "search_product"
	ActCalcQuota     Action = "calc_quota"
	ActQueryCredit   Action = "query_credit"
)

var allowed = map[Action]bool{
	ActSearchProduct: true,
	ActCalcQuota:     true,
	ActQueryCredit:   true,
}

// Plan 是模型给出的执行意图。
type Plan struct {
	Steps []Action
	Note  string
}

// Validate 把模型输出收敛成可执行计划。
// 遇到未知动作直接拒绝并回传,而不是"尽力执行",这是控制面留在代码里的关键。
func Validate(p Plan) (Plan, []Action) {
	ok := Plan{Note: p.Note}
	var rejected []Action
	for _, a := range p.Steps {
		if !allowed[a] {
			rejected = append(rejected, a)
			continue
		}
		ok.Steps = append(ok.Steps, a)
	}
	return ok, rejected
}

// Execute 只按计划顺序跑,下一步由代码决定,不由模型决定。
func Execute(p Plan, run func(Action) error) error {
	for _, a := range p.Steps {
		if err := run(a); err != nil {
			return err
		}
	}
	return nil
}

取舍在这儿:白名单压掉了模型的自由度,新增动作要改代码、要发版。有人觉得太重,为什么不让模型自己组合工具做新事。我的答案是分场景:读操作可以放开,写操作必须白名单。因为读错了顶多浪费一次调用,写错了要赔钱。

串行还是并行,是编排层的第二个决定,也是面试常问的。我的判断标准是看有没有数据依赖,而不是看能不能并行。两个工具都要用户 ID、互不依赖,就并行;后一个需要前一个的输出,就串行,这时候强行并行只会让代码多一层等待。还有一个坑:并行之后要处理部分失败,三个里有一个失败,是整单失败还是尽力返回。金融场景我选整单失败,因为部分成功的结果对用户有误导;检索类场景我选尽力返回,因为少一条召回比整个答案出不来好。

第三层:工具层

它管的是"谁有权干什么"。

工具层不是"把函数暴露给模型"这么简单。它是一个注册表,每注册一个工具都要过四道闸:参数校验、权限校验、执行边界(超时、限流)、并发控制。

判断:权限声明写在结构体字段里,不写在提示词里。

把"你只能查询不能修改"写在系统提示词里,是给自己一个心理安慰。模型可以被注入、可以在长上下文里忘掉这条。写在代码里,模型连请求都发不进来。

还有一个位置问题:权限校验要挂在注册表这一层,不要写进每个业务工具内部。挂在外层,新加的工具自动被管住;挂进业务代码里,加到第十个工具一定漏一个。

go 复制代码
package tools

import (
	"context"
	"errors"
	"fmt"
)

var ErrDenied = errors.New("permission denied")

// Call 是模型提交的一次调用请求。
type Call struct {
	Tool  string
	Args  map[string]string
	Actor string // 谁发起的,审计要用
}

// Tool 的权限声明写在结构体字段里,不写在提示词里。
type Tool struct {
	Name    string
	Scopes  []string // 调用这个工具必备的权限
	Handler func(ctx context.Context, args map[string]string) (string, error)
}

type Registry struct {
	tools map[string]Tool
}

func NewRegistry() *Registry {
	return &Registry{tools: make(map[string]Tool)}
}

func (r *Registry) Register(t Tool) error {
	if t.Name == "" || t.Handler == nil {
		return fmt.Errorf("tool %q: name and handler are required", t.Name)
	}
	if _, dup := r.tools[t.Name]; dup {
		return fmt.Errorf("tool %q: already registered", t.Name)
	}
	r.tools[t.Name] = t
	return nil
}

// Invoke 是所有副作用的唯一入口:先鉴权,再执行。
// 模型只能请求,不能决定自己有没有权限。
func (r *Registry) Invoke(ctx context.Context, c Call, granted []string) (string, error) {
	t, ok := r.tools[c.Tool]
	if !ok {
		return "", fmt.Errorf("tool %q: not found", c.Tool)
	}
	if !hasScope(granted, t.Scopes) {
		return "", fmt.Errorf("tool %q by %s: %w", c.Tool, c.Actor, ErrDenied)
	}
	return t.Handler(ctx, c.Args)
}

func hasScope(granted, need []string) bool {
	if len(need) == 0 {
		return true
	}
	set := make(map[string]bool, len(granted))
	for _, s := range granted {
		set[s] = true
	}
	for _, s := range need {
		if !set[s] {
			return false
		}
	}
	return true
}

代价很具体:新增一个工具要写 schema、写测试、配权限,接入成本从半天变成一天。值得,因为加到第八个工具时,不会突然发现某个工具的权限一直开着。

权限这道闸在上面已经过了,剩下三道要一起加,缺一道这套就是半成品:

  • 参数校验 :模型的参数是字符串,业务的参数是类型。金额是 decimal 不是 float,日期要校验格式和时区。这一步不做,脏数据会直接进到下游系统。
  • 执行边界 :每个工具必须有超时,而且超时要能被上下文取消(代码里的 ctx)。没有超时的工具,一旦下游挂起,整个任务就卡在那儿,占着并发槽位。
  • 并发控制:这一道最容易被漏。幂等只防"重复执行同一次副作用",不防"多个工具同时写同一个资源"。同时处理并发与写入的那一层,得按资源维度加锁或排队,否则两个工具各自都幂等,加起来照样把余额写花。

另外一道不是闸,是留痕:审计。谁、在什么任务下、用什么参数、调了什么工具、返回了什么,结构化记下来,出问题能按用户或任务聚合。

这四道闸里最容易被跳过的是参数校验,因为 demo 阶段参数永远是干净的。上线第一个月你就会遇到模型把"三万"传成 30000元 这种脏值。

第四层:模型层

它管的是"用哪个模型、失败怎么办、钱花在哪"。

最省事的做法是全部走 OpenAI 兼容协议,换模型等于改配置,降级时不用改业务代码。

**判断:降级链要按固定顺序,不要按成本随机挑。**这里是我见过的一个常见误区:为了压成本,按成本权重随机路由到不同模型。省是省了,但同一个会话前后两个模型,输出的格式、语气、JSON 字段名可能都不一样,前端解析直接崩。

这一层不贴代码:正确形态就两个方法。Client 只有 Name() string 和 Chat(ctx context.Context, msgs []Message) (string, error);Chain 持一个 Client 切片,Chat 里顺序试,第一个成功就返回,全失败返回最后一个错误,包一层带上模型名,方便在观测层区分是哪个模型挂的。

简单是有意的。这一层一旦复杂化,就会出现"这层知道业务字段"的代码,再也测不动。压成协议层,业务判断留在上面几层,换模型、加兜底、做灰度都只是改配置。

真要按成本省,正确切法是按任务类型切,不按调用次数切:意图识别、槽位抽取走小模型,最终答复走大模型,同一次会话内部模型保持稳定。

第五层:状态层

它管的是"跑到一半挂了,怎么接着跑"。

Agent 和普通接口最大的区别在这里:普通接口失败了重试一次就完了,Agent 的一次任务可能已经产生了三个副作用,重试一遍会重复产生。重复发一条短信是小事,重复扣一笔款是事故。

**判断:检查点加幂等键。**检查点记录"任务走到哪一步了",幂等键保证"同一个副作用只发生一次"。幂等键必须由任务、工具、参数共同决定,不能带时间戳,也不能带随机数,否则重试算出来的是另一个键,幂等就是假的。

go 复制代码
package state

import (
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"sort"
	"sync"
)

var ErrDuplicate = errors.New("duplicate side effect")

// IdemKey 由任务、工具、参数共同决定。
// 重试时必须算出同一个键,否则幂等就是假的。
func IdemKey(taskID, tool string, args map[string]string) string {
	h := sha256.New()
	h.Write([]byte(taskID))
	keys := make([]string, 0, len(args))
	for k := range args {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	for _, k := range keys {
		h.Write([]byte{0})
		h.Write([]byte(k))
		h.Write([]byte{'='})
		h.Write([]byte(args[k]))
	}
	h.Write([]byte{0})
	h.Write([]byte(tool))
	return hex.EncodeToString(h.Sum(nil))[:32]
}

// Checkpoint 记录哪些副作用已经发生过。
type Checkpoint struct {
	mu   sync.Mutex
	done map[string]bool
}

func NewCheckpoint() *Checkpoint {
	return &Checkpoint{done: make(map[string]bool)}
}

// Once 保证同一个键的副作用只发生一次。
// 返回 ErrDuplicate 时上层应当跳过而不是重试。
func (c *Checkpoint) Once(key string) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.done[key] {
		return ErrDuplicate
	}
	c.done[key] = true
	return nil
}

Once 是先写标记再执行,好处是并发下不重复,坏处是执行失败后这个键被占住、重试会被拒。生产上加三态(进行中、成功、失败),失败态允许重试。这段话讲出来,对方就知道你处理过幂等。

另外,持久化不是每一步都要落。只落有副作用的那几步,读写放大能降一个数量级。

第六层:观测层

它管的是"出事之后你能不能十分钟内定位"。

Agent 的链路比普通服务长得多,一次用户提问可能是:意图识别一次模型、计划一次模型、三次工具调用、最后一次总结。中间任何一环错,用户看到的都是"答得不对"。没有链路,你只能靠猜。

判断:一次调用要能串成一条线,请求 ID 到计划,再到每一次工具调用,最后到模型用量。

go 复制代码
package trace

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"strconv"
	"time"
)

type ctxKey struct{}

// Step 是一次可观测的最小单元:谁调的、用哪个模型、花了多少、有没有错。
type Step struct {
	Tool      string
	Model     string
	InTokens  int
	OutTokens int
	Cost      time.Duration
	Err       error
}

// Trace 把一次请求串成一条线:请求 ID 到计划,再到每一次工具调用。
type Trace struct {
	RequestID string
	PlanID    string
	Steps     []Step
}

func WithTrace(ctx context.Context, t *Trace) context.Context {
	return context.WithValue(ctx, ctxKey{}, t)
}

func From(ctx context.Context) *Trace {
	t, _ := ctx.Value(ctxKey{}).(*Trace)
	return t
}

func NewID() string {
	b := make([]byte, 8)
	if _, err := rand.Read(b); err != nil {
		return "unknown"
	}
	return hex.EncodeToString(b)
}

func (t *Trace) Record(s Step) {
	if t == nil {
		return
	}
	t.Steps = append(t.Steps, s)
}

// Line 一行出链路摘要,出事时先看这一行再看细节。
func (t *Trace) Line() string {
	if t == nil {
		return ""
	}
	return "req=" + t.RequestID + " plan=" + t.PlanID + " steps=" + strconv.Itoa(len(t.Steps))
}

取舍在采样:有副作用的调用必留全量(要能审计),纯读取的调用按 1% 到 5% 采样就够定位问题。

必须埋的点只有三个,别贪多:

  1. 请求 ID 一路透传,从入口生成,粘到每一次工具调用和每一次模型调用的 header 里。没有透传,跨服务的链路就是断的。
  2. 每一次模型调用的 token 用量,入参出参分开记。它既是成本的唯一来源,也是判断"上下文有没有爆"的唯一依据。
  3. 每一个工具的耗时和结果状态,成功、业务失败、超时要分开记,混成一个 error 就分不清是下游挂了还是参数错了。

这六层怎么用:一个排查顺序

把这六层当成一个排查清单,比当成一个架构图有用。用户反馈"答得不对"的时候,按这个顺序看,基本三轮内能定位:

  1. 观测层先看:这次请求的 step 列表里,哪一步错了、哪一步慢、哪一步 token 爆了。没有这一步,后面全是猜。
  2. 上下文层再看:那一步的入参是什么。我在线上遇到过最多次的问题,是工具返回被截断,模型拿到半段 JSON,然后"合理地"编了一个答案。
  3. 工具层第三次看:同一个参数被调了几次?如果出现重复调用,说明幂等或编排有问题,不是模型问题。
  4. 模型层最后看:前面三层都干净,才轮到怀疑模型选型或者 prompt。

顺序反了很浪费时间:一个"答得不准"的问题,先去调 prompt,调了两天,最后发现是上下文裁剪把关键字段切掉了。

常见的几个症状,可以对着这张表先定位到层,再进细节:

症状 先查哪一层 典型原因
模型答得"很流畅但不对" 上下文 工具返回被截断或没摘要,模型拿残数据编
同一个工具一次任务里被调了两次 状态或编排 缺幂等键,或重试逻辑没有检查点
偶尔出现奇怪的越权报错 工具 权限是按用户授予的,模型换了 Actor 没切
延迟忽高忽低,P99 离谱 模型或工具 降级链里混了不同速度的模型,或工具没超时
上游改了字段名,线上没报错但结果全错 工具 参数校验只校验存在性,没校验类型和取值

回到那个卡顿

回到开头那个卡顿。面试官问 harness 架构,你要答的不是六层的名字,是你为什么这么分。名字可以背,边界背不出来。

如果你只能记住一句话,记这句:**副作用必须收口,权限不能交给模型。**这句话包含整层的设计动机:模型不确定,代码确定,所以让不确定的部分只负责提议,确定的部分负责执行和拒绝。


你们项目里这六层分别落在哪几个文件?评论区说说,我看看有没有比我更乱的。

相关推荐
琳达爱分享1 小时前
四种限流算法实测对比:固定窗口、滑动窗口、漏桶、令牌桶(附 Redis+Lua 实现)
后端·架构
吱吱鼠1 小时前
四、搞懂文件存储管理:多级索引寻道、位示图秒级分配与访问控制体系
架构
吱吱鼠1 小时前
三、搞懂文件系统的物理存储结构:连续、链式、索引与多级索引
架构
用户1100549461192 小时前
同一个发文任务,截图流 vs Playwright MCP vs 语义树:我把 token 和失败次数算给你看
agent
Databend2 小时前
Databend 8-9 月双月报:物化视图、数据共享与数据血缘能力升级
大数据·数据分析·agent
zhangjw342 小时前
1-3 PostgreSQL 架构模型:实例、数据库、Schema 模式、表空间完整概念
数据库·postgresql·架构
软件工程师文艺2 小时前
从微服务到 Serverless:OAM 模型如何统一应用描述与交付
微服务·架构·serverless
Databend2 小时前
周年庆结束了,湖仓为什么还在高消费?
大数据·数据库·agent
吴建旭 智宅焕2 小时前
智能家居全国交付中B端客户的交付差异化能力架构:从低客单价依赖到交付确定性基础设施
架构·智能家居