面试里被问"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% 采样就够定位问题。
必须埋的点只有三个,别贪多:
- 请求 ID 一路透传,从入口生成,粘到每一次工具调用和每一次模型调用的 header 里。没有透传,跨服务的链路就是断的。
- 每一次模型调用的 token 用量,入参出参分开记。它既是成本的唯一来源,也是判断"上下文有没有爆"的唯一依据。
- 每一个工具的耗时和结果状态,成功、业务失败、超时要分开记,混成一个 error 就分不清是下游挂了还是参数错了。
这六层怎么用:一个排查顺序
把这六层当成一个排查清单,比当成一个架构图有用。用户反馈"答得不对"的时候,按这个顺序看,基本三轮内能定位:
- 观测层先看:这次请求的 step 列表里,哪一步错了、哪一步慢、哪一步 token 爆了。没有这一步,后面全是猜。
- 上下文层再看:那一步的入参是什么。我在线上遇到过最多次的问题,是工具返回被截断,模型拿到半段 JSON,然后"合理地"编了一个答案。
- 工具层第三次看:同一个参数被调了几次?如果出现重复调用,说明幂等或编排有问题,不是模型问题。
- 模型层最后看:前面三层都干净,才轮到怀疑模型选型或者 prompt。
顺序反了很浪费时间:一个"答得不准"的问题,先去调 prompt,调了两天,最后发现是上下文裁剪把关键字段切掉了。
常见的几个症状,可以对着这张表先定位到层,再进细节:
| 症状 | 先查哪一层 | 典型原因 |
|---|---|---|
| 模型答得"很流畅但不对" | 上下文 | 工具返回被截断或没摘要,模型拿残数据编 |
| 同一个工具一次任务里被调了两次 | 状态或编排 | 缺幂等键,或重试逻辑没有检查点 |
| 偶尔出现奇怪的越权报错 | 工具 | 权限是按用户授予的,模型换了 Actor 没切 |
| 延迟忽高忽低,P99 离谱 | 模型或工具 | 降级链里混了不同速度的模型,或工具没超时 |
| 上游改了字段名,线上没报错但结果全错 | 工具 | 参数校验只校验存在性,没校验类型和取值 |
回到那个卡顿
回到开头那个卡顿。面试官问 harness 架构,你要答的不是六层的名字,是你为什么这么分。名字可以背,边界背不出来。
如果你只能记住一句话,记这句:**副作用必须收口,权限不能交给模型。**这句话包含整层的设计动机:模型不确定,代码确定,所以让不确定的部分只负责提议,确定的部分负责执行和拒绝。
你们项目里这六层分别落在哪几个文件?评论区说说,我看看有没有比我更乱的。