能力三角色与 LLM 适配器

能力三角色与 LLM 适配器

一句话总结

官方每一项可替换能力都由三个角色构成:Definition(定义接口契约)、Provider(提供具体实现)、Consumer(按契约消费而不依赖实现);LLM 能力是最典型的例子——通过统一的 LLMAdapter 接口与 StreamChunk 流式协议,dsh-go 可以接入任意 OpenAI 兼容模型端点而不改动上层循环。

能力三角色

官方文档里反复出现 Service Definition、Service Provider、Consumer 三个词,它们合在一起构成一项能力的 seam(缝)——也就是可以在不改动消费方的前提下替换实现的接口边界:

角色职责dsh-go 中的对应
Definition定义能力的接口与数据契约Go interface + 请求/结果结构体
Provider给出一个具体实现并注册实现接口,在装配时 reg.Put
Consumer按 key/接口查找并使用,不导入实现reg.Get 或构造时注入接口

关键约束是消费方依赖抽象而非具体实现:Agent 循环只知道自己拿到了一个 LLMAdapter,不关心背后是 DeepSeek、本地模型还是一个测试桩。正因如此,替换模型提供方、在测试里注入假实现,都不需要改动 Agent 一行代码。

1// Definition:接口即契约
2type LLMAdapter interface {
3    Name() string
4    Chat(ctx context.Context, req ChatRequest, cb func(StreamChunk)) (Usage, error)
5}
6// Consumer:只依赖接口
7func NewAgent(llm LLMAdapter) *Agent { ... }
8// Provider:任意实现该接口的具体适配器

LLM 适配器:接入任意模型

模型提供方不止一家。想把 Agent 接到自己的模型端点,只需实现 pkg/llm.LLMAdapter 两个方法:

  • Name() 返回适配器标识(如 "deepseek");
  • Chat(ctx, req, cb) 发起一次流式对话,每个分片通过 cb 回调,最终返回本次用量 Usage。

适配器负责把 dsh-go 统一的 ChatRequest(消息列表、工具声明、采样参数)翻译成目标端点的线格式,再把端点返回的 SSE 流翻译回统一的 StreamChunk。上层因此完全模型无关,模型路由也无需重启。

StreamChunk 流式协议

Chat 的 cb 到底往外出什么数据?pkg/llm.StreamChunk 用一个 Kind 字段区分四种分片:

Kind载荷含义
ChunkText正文增量(逐 token 拼接)
ChunkReasoning思维链增量
ChunkToolCall工具调用增量(参数可能分片到达)
ChunkDone本次流结束
1_, _ = llm.Chat(ctx, req, func(c llm.StreamChunk) {
2    switch c.Kind {
3    case llm.ChunkText:
4        ui.Append(c.Text)
5    case llm.ChunkToolCall:
6        // 累积工具调用,可能需要跨分片拼接参数
7    }
8})

流中的错误处理

流式场景的错误比一次性请求更隐蔽:连接可能在中途断开、某个分片可能畸形。dsh-go 的处理原则是:

  • 分片级容错:单个分片解析失败不应让已收到的正文丢失,记录错误并决定是否继续;
  • 错误链:pkg/llm/errorchain.go 把底层网络错误、HTTP 状态错误、上下文取消统一包装为可判别的链,可用 errors.Is/As 区分"可重试"与"确定性失败";
  • 重试:pkg/llm/retry.go 只对可重试错误(超时、429、5xx)退避重试,确定性失败(4xx 鉴权错误)立即返回,不做无意义重试;
  • 缓存:pkg/cache 对确定性请求提供命中,降低重复调用成本与延迟。

输入边界:外部数据进入前的校验

能力接缝不只是"接得上",外部数据在进入内核前必须先过一道边界校验,dsh-go 对应实现:

  • 附件准入:attachment.DecodeCanonicalBase64 解码后再编码比对,拒绝非规范 base64;attachment.NewLimiter 用固定并发上限约束图像变换,超出排队;
  • 反馈值约束:feedback.ValidateRating 只接受 thumbs up/down(0=未评分不落库),ValidateNote 要求非空备注含非空白字符;
  • 技能语法:skills.IsName 校验 kebab-case 名称,RenderContent 渲染统一 <skill_content> 块并转义内嵌文本;
  • MCP 公共名:mcp.PublicToolName 生成 mcp__server__raw,字符替换或截断时追加身份哈希,防止不同外部身份塌缩为同名。

匿名身份:与机器解耦

遥测与反馈需要一个稳定的用户标识,但不应从主机名、网络地址等可识别信息派生。telemetry.GetOrCreateAnonymousID 在每个 Harness home 下生成一个随机 UUID 并持久化为 .anonymous-user-id:同一 home 的进程共享,删除文件即换新身份;写入失败也会返回本次可用的 ID,不阻塞遥测与反馈。

为什么三角色很重要?

这套结构是"一切皆插件、能力可自由替换"的落地方式。它的收益:

  • 可替换:换 Provider 不动 Consumer,模型/工具/存储皆然;
  • 可测试:测试里注入实现接口的桩,即可离线验证整个 Agent 循环;
  • 可组合:多个 Provider 可以被更上层的路由适配器聚合,按场景选择。

代价是前期要先想清楚接口契约——Definition 设计得不稳,所有 Consumer 都会跟着改。

源码对照

概念Go 实现官方 TypeScript
适配器契约pkg/llm/llm.go — LLMAdapterpackages/core/llm adapter
流式协议pkg/llm/llm.go — StreamChunk/StreamChunkKindstream chunk 协议
错误链pkg/llm/errorchain.go(dsh-go 增强)
重试pkg/llm/retry.go(dsh-go 增强)
能力注册pkg/registry/registry.go — FreezableCordis service 注册
附件准入/限流pkg/attachment/admission.go、limiter.goattachment/admission、compression-limiter
反馈值约束pkg/feedback/validate.gomessage-feedback/spec
技能语法pkg/skills/grammar.goskill/src/index
MCP 公共名pkg/mcp/publicname.gomcp-client/tools
匿名身份pkg/telemetry/anonymous.goidentity/anonymous-user-id

下一步