关于 harness9
在上一篇文章中,我们详细拆解了 harness9 框架的核心 AgentLoop 设计。延续这一路径,本篇我们继续深入分析下 harness9 的 Tool-Calling 工具调用系统的核心思想。
harness9 是一款轻量、完备、生产可用的 Go 语言 Agent Harness 框架。
- 官网:https://zhangshenao.github.io/harness9/
- GitHub:https://github.com/ZhangShenao/harness9
Star 是对开源工作最直接的支持,欢迎提 Issue 和 PR。
TL;DR
harness9 的工具调用(Tool Calling)系统围绕三个核心决策展开:接口在使用者侧定义、并发执行保序写入、错误原样回传触发自愈。路径沙箱与路径级读写锁是生产可用的安全底线,edit_file 的四级模糊匹配是对 LLM 输出不稳定性的系统性对抗。
一、数据类型层:三个类型撑起整个协议
工具调用系统的契约从internal/schema/message.go中三个类型出发。
typeToolCallstruct{IDstringNamestringArguments json.RawMessage// 延迟反序列化,解析责任在具体工具}typeToolResultstruct{ToolCallIDstringOutputstringIsErrorbool// true 时引擎将错误原文回传给 LLM}typeToolDefinitionstruct{NamestringDescriptionstringInputSchema any// 各 Provider 自行适配 SDK 类型}Arguments使用json.RawMessage是一个蓄意的设计选择。引擎层不知道、也不需要知道每个工具的参数结构。类型安全边界被推迟到工具实现内部,代价是每个工具都要自行调用json.Unmarshal,收益是引擎与工具完全解耦——新增工具不需要改引擎任何一行代码。
InputSchema使用any同理。内置工具以map[string]interface{}形式声明 JSON Schema,各 Provider 适配器再把它转换为自家 SDK 要求的类型(OpenAI 的shared.FunctionParameters、Anthropic 的map[string]any),schema 包本身不感知厂商差异。
二、接口层:定义在使用者侧
harness9 的接口设计遵循 Go 惯例:接口声明在依赖方,而非实现方。
BaseTool接口定义在internal/tools/base.go,Registry接口也在同一个包:
typeBaseToolinterface{Name()stringDefinition()schema.ToolDefinitionExecute(ctx context.Context,args json.RawMessage)(string,error)}typeRegistryinterface{Register(tool BaseTool)errorGetAvailableTools()[]schema.ToolDefinitionExecute(ctx context.Context,call schema.ToolCall)schema.ToolResult}引擎包(internal/engine)依赖tools.Registry接口,而非registryImpl具体类型。这意味着测试时可以注入任意 mock,生产代码不需要任何改动。
Registry.Execute的签名值得注意——它接收schema.ToolCall,返回schema.ToolResult,而不是(string, error)。这个封装在注册表层完成了一次关键的语义转换:工具执行失败不再是 Go 层面的error,而是IsError=true的ToolResult。引擎可以把这条失败记录作为普通 Observation 注入上下文,LLM 在下一轮推理时看到错误信息,自行决定如何处理。
func(r*registryImpl)Execute(ctx context.Context,call schema.ToolCall)schema.ToolResult{tool,exists:=r.tools[call.Name]if!exists{returnschema.ToolResult{ToolCallID:call.ID,Output:fmt.Sprintf("Error: 系统中不存在名为 '%s' 的工具。",call.Name),IsError:true,}}output,err:=tool.Execute(ctx,call.Arguments)iferr!=nil{returnschema.ToolResult{ToolCallID:call.ID,Output:fmt.Sprintf("Error executing %s: %v",call.Name,err),IsError:true,}}returnschema.ToolResult{ToolCallID:call.ID,Output:output}}错误不终止循环,错误是下一轮推理的原材料——这是 harness9 自愈(Self-Healing)能力的物质基础。
三、并发执行模型:预分配切片 + 索引写入
主流 LLM(GPT、Claude)可以在单次响应中发出多个ToolCall。harness9 在引擎层并发执行它们:
func(e*AgentEngine)executeTools(ctx context.Context,turnint,toolCalls[]schema.ToolCall,logPrefixstring,em emitter)[]schema.ToolResult{results:=make([]schema.ToolResult,len(toolCalls))// 预分配varwg sync.WaitGroupvarsemchanstruct{}ife.maxConcurrentTools>0{sem=make(chanstruct{},e.maxConcurrentTools)// 并发度限制}fori,toolCall:=rangetoolCalls{wg.Add(1)gofunc(idxint,tc schema.ToolCall){deferwg.Done()ifsem!=nil{sem<-struct{}{}deferfunc(){<-sem}()}toolCtx:=ctxvarcancel context.CancelFuncife.toolTimeout>0{toolCtx,cancel=context.WithTimeout(ctx,e.toolTimeout)defercancel()}// ...results[idx]=e.registry.Execute(toolCtx,tc)// 索引写入}(i,toolCall)}wg.Wait()returnresults}两个并发安全细节值得单独拎出来说:
预分配 + 索引写入:results在启动 goroutine 之前就已分配好长度,每个 goroutine 通过闭包捕获的idx写入固定位置,不同 goroutine 写不同槽位,无竞态条件,也不需要任何锁。Go 的内存模型保证这种模式是安全的。
每工具独立超时:每个 goroutine 内部通过context.WithTimeout(ctx, e.toolTimeout)创建子上下文。一个工具超时只会取消自己的子上下文,不会影响同 Turn 内其他工具的执行。这是toolCtx而非共享ctx传给registry.Execute的原因。
并发度通过maxConcurrentTools选项控制——信道作信号量,sem <- struct{}{}阻塞表示占位,<-sem释放槽位。设为 0 时不限制并发度。
引擎的默认配置是maxTurns=50, toolTimeout=60s,为生产场景提供合理的上限兜底。
四、路径沙箱:safePath 的两层防线
bash工具不做命令限制,read_file和write_file则受到safePath保护。两者的安全哲学截然不同,但都是刻意的选择。
safePath的核心逻辑在internal/tools/safe_path.go:
funcsafePath(workDir,inputPathstring)(string,error){// 绝对路径输入先检:在 Join 前直接拦截敏感路径iffilepath.IsAbs(inputPath){cleanInput:=filepath.Clean(inputPath)ifisSensitivePath(cleanInput){return"",fmt.Errorf("路径 '%s' 是受保护的敏感路径,禁止访问",inputPath)}}cleanWorkDir:=filepath.Clean(workDir)joined:=filepath.Join(cleanWorkDir,inputPath)absPath,err:=filepath.Abs(joined)// ...// 前缀必须是 cleanWorkDir + PathSeparator,不能只是 cleanWorkDir// 否则 "/project-evil" 会被误判为 "/project" 的合法子路径if!strings.HasPrefix(absPath,cleanWorkDir+string(os.PathSeparator))&&absPath!=cleanWorkDir{return"",fmt.Errorf("路径 '%s' 超出工作区范围",inputPath)}ifisSensitivePath(absPath){// 二次检查:Join 后再过一遍return"",fmt.Errorf("路径 '%s' 是受保护的敏感路径,禁止访问",inputPath)}returnabsPath,nil}两层防线各有针对的攻击向量:
第一层(绝对路径预检)针对直接提供绝对路径的情形,在filepath.Join前就拦截,防止攻击者通过/home/user/.ssh/id_rsa绕过相对路径沙箱。
第二层(Join 后前缀校验)针对../../etc/passwd这类相对路径穿越。filepath.Abs会把Join("/project", "../../etc/passwd")解析成/etc/passwd,然后前缀校验发现它不以/project/开头,直接拒绝。
注释里那个/project-evil细节是真实 bug 的防范——纯字符串前缀匹配时/project-evil会通过/project的检查,但加上PathSeparator就不会了。
硬编码的敏感路径列表包含~/.ssh、~/.aws、~/.kube、~/.gnupg、~/.netrc、~/.config/gcloud——这些是凭证泄漏风险最高的目录,无论workDir设置成什么都会被拒绝。
五、路径级锁:比全局锁细一个量级
safePath防的是越界,路径级读写锁防的是同一文件上的并发竞争。
internal/tools/path_locker.go实现了一套引用计数的路径粒度锁:
typepathLockstruct{rw*sync.RWMutex refint}var(pathLocksMu sync.Mutex pathLocks=make(map[string]*pathLock))funcRLockPath(pathstring)func(){l:=getOrCreatePathLock(path)// ref++l.rw.RLock()returnfunc(){l.rw.RUnlock()releasePathLock(path,l)// ref--,归零时从 map 删除}}使用侧极其简洁:
// read_file.gounlock:=RLockPath(fullPath)deferunlock()// write_file.go / edit_file.gounlock:=LockPath(fullPath)deferunlock()这个设计的关键属性:
不同路径之间完全无竞争。同时读取a.go和b.go的两个 goroutine 拿到的是不同的RWMutex,互不阻塞。只有操作同一路径的并发调用才会发生锁竞争。
引用计数解决了 map 的无限膨胀问题。没有活跃使用者的路径条目会从pathLocks中删除,不会因为历史操作路径数量增长而导致内存泄漏。
getOrCreatePathLock和releasePathLock都用pathLocksMu互斥保护 map 操作,确保ref++和ref--的原子性。
与全局sync.RWMutex相比,路径级锁的优势在吞吐量上:LLM 同时调用read_file("a.go")和write_file("b.go")时,两个操作可以完全并行。
六、edit_file 的四级模糊匹配
edit_file是内置工具中设计最复杂的一个,它解决的问题是:LLM 生成的source_text和文件里的实际内容经常不完全一样。
四级容错流水线(Four-Level Fallback Pipeline)在fuzzyReplace函数中展开:
// L1: 精确匹配count:=strings.Count(originalContent,sourceText)ifcount==1{returnstrings.Replace(originalContent,sourceText,targetText,1),nil}ifcount>1{return"",fmt.Errorf("source_text 匹配到了 %d 处,请提供更多的上下文代码以确保唯一性",count)}// 进入 L2-L4,先做换行符归一化normalizedContent:=strings.ReplaceAll(originalContent,"\r\n","\n")normalizedSource:=strings.ReplaceAll(sourceText,"\r\n","\n")// L2: 换行符归一化匹配count=strings.Count(normalizedContent,normalizedSource)ifcount==1{/* 替换,按需恢复 \r\n */}// L3: 整体首尾去空trimmedSource:=strings.TrimSpace(normalizedSource)iftrimmedSource!=""{count=strings.Count(normalizedContent,trimmedSource)ifcount==1{/* 替换 */}}// L4: 逐行去缩进滑动窗口匹配returnlineByLineReplace(normalizedContent,normalizedSource,normalizedTarget,hasCRLF)每一级都有唯一性校验(Uniqueness Guard):count > 1 时直接返回错误,要求 LLM 提供更多上下文,而不是猜测匹配哪一处。这避免了"错改正确代码"这类沉默的破坏性错误。
L4 的逐行去缩进是最后的容错防线,专门应对 LLM 对缩进的不稳定输出。它用滑动窗口逐行比较strings.TrimSpace后的内容,容忍空格和 Tab 的差异。
换行风格保留是一个细节:L2 及以下的替换在归一化内容(\n)上完成,写回前检查原始文件是否含\r\n,如果有就恢复,确保跨平台兼容。
这套机制的工程意义在于:LLM 不需要完美地复现代码格式,框架会帮它找到最接近的匹配。同时,唯一性校验确保这种"宽容"不会变成"危险"。
七、bash 工具的 YOLO 哲学与双重超时
bash工具与其他文件工具在安全哲学上完全不同。它不做路径沙箱,也不做命令白名单。
constbashHardTimeout=30*time.Secondfunc(t*BashTool)Execute(ctx context.Context,args json.RawMessage)(string,error){// ...timeoutCtx,cancel:=context.WithTimeout(ctx,bashHardTimeout)defercancel()cmd:=exec.CommandContext(timeoutCtx,"bash","-c",input.Command)cmd.Dir=t.workDir out,err:=cmd.CombinedOutput()iftimeoutCtx.Err()==context.DeadlineExceeded{returnoutputStr+"\n[警告: 命令执行超时(30s),已被系统强制终止。]",nil}iferr!=nil{// 注意:返回 (string, nil),不是 (string, error)returnfmt.Sprintf("执行报错: %v\n输出:\n%s",err,outputStr),nil}// ...}三个设计点:
bash -c包裹支持完整 Shell 语法——管道、逻辑与、环境变量、重定向,LLM 不需要拆分命令。
双重超时兜底:引擎层的toolTimeout(默认 60s)和工具内的bashHardTimeout(30s),实际生效的是两者中较短的那个。这是针对tail -f、top、Web 服务等阻塞型命令的"安全网"。
命令失败时返回(string, nil)而非(string, error)——这是 YOLO 哲学的实现细节。返回nil意味着 Registry 会生成IsError=false的ToolResult,错误内容(包含 exit code 和 stderr)作为普通文本进入上下文,LLM 阅读后自行决策。不做半吊子沙箱:bash 工具本质上提供完整 shell 访问,加cd /就能逃逸 workDir,做命令白名单只是制造安全假象。如果需要路径安全,用read_file和write_file。
八、工具系统的整体鸟瞰
把上面所有层次拼在一起:
LLM Provider │ ToolCall[](含 json.RawMessage Arguments) ▼ AgentEngine.executeTools ├── goroutine[0] → toolCtx(独立超时)→ Registry.Execute → BashTool ├── goroutine[1] → toolCtx(独立超时)→ Registry.Execute → ReadFileTool │ └── safePath → RLockPath → os.Open └── goroutine[2] → toolCtx(独立超时)→ Registry.Execute → EditFileTool └── safePath → LockPath → fuzzyReplace ↓ sync.WaitGroup.Wait() results[0..n](预分配,无竞态) │ IsError=true 时原样回传 ▼ ContextHistory(RoleUser + ToolCallID 关联) │ ▼ 下一轮 LLM 推理结语
harness9 的工具调用系统是框架"简洁但完备"原则的缩影:3 个核心类型、2 个接口、1 个并发执行函数,加上 safePath 和路径级锁构成的安全底座,edit_file 的四级模糊匹配应对 LLM 输出的不确定性。
值得思考的问题:当工具数量增长到数十个、LLM 每次可能发出 10 个并发调用时,maxConcurrentTools和toolTimeout的最优配置是什么?这个问题的答案,很大程度上取决于具体工具的 I/O 特性和模型的调用习惯。