【AI Agent 与 Super Agent 构建实战】第 09 篇:常用内置工具集实现 — 搜索、计算、代码执行

本系列定位:用 Go 语言从零构建各类 AI Agent,覆盖 ReAct、Plan-and-Execute、Multi-Agent、Super Agent 等核心设计模式的完整工程实现。

承上启下:第 08 篇我们搭好了工具注册框架,但工具还是空的。一个 Agent 能不能真正干活,取决于它手里有没有趁手的工具。本篇我们实现 5 个生产可用的内置工具,每一个都包含安全考量——因为 Agent 会自主调用这些工具,任何一个设计漏洞都可能被 LLM 的「创造性」参数放大成事故。


本篇你将学到

  • Web Search 工具:封装搜索 API + 结果结构化 + 抽象 Provider 接口
  • 安全计算器:用 Pratt 解析器做表达式求值,杜绝 eval 式注入
  • 沙箱代码执行:os/exec + 超时 + 资源限制,让 Agent 安全跑代码
  • 文件读写工具:白名单目录 + 路径穿越防御
  • 通用 HTTP 调用器:给 Agent 一个「万能接口」能力

一句话总结:内置工具的质量直接决定 Agent 的能力边界——而质量 = 功能完整性 × 安全性,两者缺一不可。


一、Web Search 工具:Agent 的「眼睛」

Agent 经常需要查实时信息(新闻、文档、价格)。Web Search 是最高频的工具之一。

1.1 设计要点

具体实现

搜索抽象

工具层

WebSearch Tool

SearchProvider 接口

必应/Bing

SerpAPI

Mock 模拟

关键设计是抽象出 SearchProvider 接口,让工具不绑定具体厂商。开发用 Mock,测试用 SerpAPI,生产用必应,切换只改一行配置。

1.2 SearchProvider 接口

// internal/tools/search/provider.go
package search

import "context"

// SearchResult 单条搜索结果
type SearchResult struct {
	Title   string `json:"title"`
	URL     string `json:"url"`
	Snippet string `json:"snippet"` // 摘要(LLM 主要看这个)
}

// SearchProvider 搜索后端抽象
type SearchProvider interface {
	Search(ctx context.Context, query string, maxResults int) ([]SearchResult, error)
	Name() string
}

1.3 WebSearch 工具实现

// internal/tools/search/websearch.go
package search

import (
	"context"
	"fmt"
	"strings"

	"github.com/yourname/agentforge/internal/registry"
)

// WebSearch 工具
type WebSearch struct {
	provider SearchProvider
}

// NewWebSearch 创建工具,注入一个搜索后端
func NewWebSearch(p SearchProvider) *WebSearch {
	return &WebSearch{provider: p}
}

func (w *WebSearch) Metadata() registry.ToolMetadata {
	return registry.ToolMetadata{
		Name:        "search_web",
		Group:       "search",
		Description: "搜索互联网获取最新信息。适用于需要实时数据、新闻、文档或未知事实的场景。输入搜索关键词,返回若干条结果的标题和摘要。",
		Parameters: map[string]registry.ParamSpec{
			"query": {
				Type:        "string",
				Description: "搜索关键词,建议用中英文混合以提高准确率,如 'Go 1.23 release notes'",
			},
			"max_results": {
				Type:        "number",
				Description: "返回结果数量,默认 5,范围 1-10",
				Default:     5,
			},
		},
		Required: []string{"query"},
		Timeout:  15,
	}
}

func (w *WebSearch) Execute(ctx context.Context, args map[string]interface{}) (registry.Result, error) {
	query, ok := args["query"].(string)
	if !ok || query == "" {
		return registry.Result{}, fmt.Errorf("参数 query 不能为空")
	}
	maxResults := 5
	if n, ok := args["max_results"].(float64); ok && n > 0 {
		maxResults = int(n)
		if maxResults > 10 {
			maxResults = 10 // 上限保护
		}
	}

	results, err := w.provider.Search(ctx, query, maxResults)
	if err != nil {
		return registry.Result{}, fmt.Errorf("搜索失败: %w", err)
	}

	// 把结果格式化成 LLM 易读的文本
	return registry.Result{Content: formatResults(query, results)}, nil
}

// formatResults 把结构化结果转成纯文本喂给 LLM
func formatResults(query string, results []SearchResult) string {
	var b strings.Builder
	fmt.Fprintf(&b, "搜索 '%s' 共返回 %d 条结果:\n\n", query, len(results))
	for i, r := range results {
		fmt.Fprintf(&b, "%d. %s\n   %s\n   来源: %s\n\n", i+1, r.Title, r.Snippet, r.URL)
	}
	return b.String()
}

为什么 max_results 强制上限 10? 因为 LLM 偶尔会传一个离谱的值(比如 1000),既浪费配额又撑爆上下文窗口。工具自己设上限是防御性编程的体现。

1.4 一个 Mock Provider(开发和测试用)

// internal/tools/search/mock.go
package search

import "context"

// MockProvider 模拟搜索,返回固定数据,用于无网络环境开发和单测
type MockProvider struct{}

func (m *MockProvider) Name() string { return "mock" }
func (m *MockProvider) Search(ctx context.Context, query string, n int) ([]SearchResult, error) {
	return []SearchResult{
		{
			Title:   "Go 1.23 Release Notes - The Go Programming Language",
			URL:     "https://go.dev/doc/go1.23",
			Snippet: "Go 1.23 于 2024 年 8 月发布。主要新特性: range-over-func、iter 包、slices 和 maps 的迭代器方法。",
		},
		{
			Title: "What's new in Go 1.23",
			URL:   "https://example.com/go123",
			Snippet: "本文详解 range-over-func 语法和自定义迭代器的实现模式。",
		},
	}, nil
}

二、安全计算器:告别字符串 eval

第 04 篇我们写过一个简化版 safeEval,只支持 sqrt 和幂运算。真实需求里 LLM 会传各种表达式:3.14 * (2 + 8) / 5sin(1.57)log(100)。我们不能用任何形式的「字符串直接执行」——那等于把代码注入漏洞拱手送给 LLM。

2.1 正确做法:词法分析 + Pratt 解析器

表达式: 3.14*(2+8)/5

词法分析 Lexer
切成 Token 流

语法分析 Parser
构建 AST

求值 Evaluator
遍历 AST 算结果

结果: 6.28

2.2 Token 与 Lexer

// internal/tools/calc/lexer.go
package calc

import (
	"fmt"
	"strings"
	"unicode"
)

// TokenType Token 类型
type TokenType int

const (
	TokNumber TokenType = iota
	TokPlus
	TokMinus
	TokStar
	TokSlash
	TokCaret // ^
	TokLParen
	TokRParen
	TokComma
	TokIdent // 函数名/变量名
	TokEOF
)

// Token 一个词法单元
type Token struct {
	Type  TokenType
	Value string
}

// Lexer 词法分析器
type Lexer struct {
	input string
	pos   int
}

func NewLexer(input string) *Lexer {
	return &Lexer{input: strings.TrimSpace(input)}
}

// NextToken 返回下一个 Token
func (l *Lexer) NextToken() (Token, error) {
	l.skipWhitespace()
	if l.pos >= len(l.input) {
		return Token{Type: TokEOF}, nil
	}

	ch := l.input[l.pos]
	switch ch {
	case '+':
		l.pos++
		return Token{TokPlus, "+"}, nil
	case '-':
		l.pos++
		return Token{TokMinus, "-"}, nil
	case '*':
		l.pos++
		return Token{TokStar, "*"}, nil
	case '/':
		l.pos++
		return Token{TokSlash, "/"}, nil
	case '^':
		l.pos++
		return Token{TokCaret, "^"}, nil
	case '(':
		l.pos++
		return Token{TokLParen, "("}, nil
	case ')':
		l.pos++
		return Token{TokRParen, ")"}, nil
	case ',':
		l.pos++
		return Token{TokComma, ","}, nil
	}

	// 数字(含小数)
	if unicode.IsDigit(rune(ch)) || ch == '.' {
		start := l.pos
		for l.pos < len(l.input) {
			c := l.input[l.pos]
			if !unicode.IsDigit(rune(c)) && c != '.' {
				break
			}
			l.pos++
		}
		return Token{TokNumber, l.input[start:l.pos]}, nil
	}

	// 标识符(函数名)
	if unicode.IsLetter(rune(ch)) {
		start := l.pos
		for l.pos < len(l.input) && unicode.IsLetter(rune(l.input[l.pos])) {
			l.pos++
		}
		return Token{TokIdent, l.input[start:l.pos]}, nil
	}

	return Token{}, fmt.Errorf("非法字符: %c", ch)
}

func (l *Lexer) skipWhitespace() {
	for l.pos < len(l.input) && unicode.IsSpace(rune(l.input[l.pos])) {
		l.pos++
	}
}

2.3 AST 节点与 Pratt 解析器

// internal/tools/calc/parser.go
package calc

import (
	"fmt"
	"math"
	"strconv"
)

// Node AST 节点接口
type Node interface {
	Eval() (float64, error)
}

// numberNode 数字字面量
type numberNode struct{ value float64 }
func (n *numberNode) Eval() (float64, error) { return n.value, nil }

// binOpNode 二元运算
type binOpNode struct {
	op       byte
	left, right Node
}
func (n *binOpNode) Eval() (float64, error) {
	l, err := n.left.Eval()
	if err != nil {
		return 0, err
	}
	r, err := n.right.Eval()
	if err != nil {
		return 0, err
	}
	switch n.op {
	case '+':
		return l + r, nil
	case '-':
		return l - r, nil
	case '*':
		return l * r, nil
	case '/':
		if r == 0 {
			return 0, fmt.Errorf("除零错误")
		}
		return l / r, nil
	case '^':
		return math.Pow(l, r), nil
	}
	return 0, fmt.Errorf("未知运算符 %c", n.op)
}

// callNode 函数调用
type callNode struct {
	fn   string
	args []Node
}
func (n *callNode) Eval() (float64, error) {
	vals := make([]float64, len(n.args))
	for i, a := range n.args {
		v, err := a.Eval()
		if err != nil {
			return 0, err
		}
		vals[i] = v
	}
	// 白名单函数,杜绝任意函数调用
	switch n.fn {
	case "sqrt":
		if len(vals) != 1 { return 0, fmt.Errorf("sqrt 需 1 个参数") }
		return math.Sqrt(vals[0]), nil
	case "sin":
		return math.Sin(vals[0]), nil
	case "cos":
		return math.Cos(vals[0]), nil
	case "log":
		if vals[0] <= 0 { return 0, fmt.Errorf("log 参数必须 >0") }
		return math.Log10(vals[0]), nil
	case "ln":
		return math.Log(vals[0]), nil
	case "abs":
		return math.Abs(vals[0]), nil
	}
	return 0, fmt.Errorf("未知函数 %s", n.fn)
}

// Parser 递归下降 + 优先级爬升(Pratt 风格)
type Parser struct {
	lexer *Lexer
	cur   Token
}

func NewParser(input string) (*Parser, error) {
	l := NewLexer(input)
	p := &Parser{lexer: l}
	if err := p.advance(); err != nil {
		return nil, err
	}
	return p, nil
}

func (p *Parser) advance() error {
	tok, err := p.lexer.NextToken()
	if err != nil {
		return err
	}
	p.cur = tok
	return nil
}

// Parse 解析整个表达式
func Parse(input string) (Node, error) {
	p, err := NewParser(input)
	if err != nil {
		return nil, err
	}
	node, err := p.parseExpression(0)
	if err != nil {
		return nil, err
	}
	if p.cur.Type != TokEOF {
		return nil, fmt.Errorf("未消费的 token: %v", p.cur)
	}
	return node, nil
}

// parseExpression 优先级爬升算法
func (p *Parser) parseExpression(minPrec int) (Node, error) {
	left, err := p.parsePrimary()
	if err != nil {
		return nil, err
	}
	for {
		prec := precedence(p.cur)
		if prec < minPrec || p.cur.Type == TokEOF {
			break
		}
		op := p.cur
		p.advance()
		right, err := p.parseExpression(prec + 1)
		if err != nil {
			return nil, err
		}
		left = &binOpNode{op: op.Value[0], left: left, right: right}
	}
	return left, nil
}

func precedence(t Token) int {
	switch t.Type {
	case TokPlus, TokMinus:
		return 1
	case TokStar, TokSlash:
		return 2
	case TokCaret:
		return 3
	}
	return 0
}

// parsePrimary 处理数字、括号、一元负号、函数调用
func (p *Parser) parsePrimary() (Node, error) {
	switch p.cur.Type {
	case TokNumber:
		v, err := strconv.ParseFloat(p.cur.Value, 64)
		if err != nil {
			return nil, err
		}
		p.advance()
		return &numberNode{value: v}, nil
	case TokLParen:
		p.advance()
		node, err := p.parseExpression(0)
		if err != nil {
			return nil, err
		}
		if p.cur.Type != TokRParen {
			return nil, fmt.Errorf("缺少右括号")
		}
		p.advance()
		return node, nil
	case TokMinus: // 一元负号
		p.advance()
		node, err := p.parsePrimary()
		if err != nil {
			return nil, err
		}
		return &binOpNode{op: '-', left: &numberNode{value: 0}, right: node}, nil
	case TokIdent: // 函数调用
		fn := p.cur.Value
		p.advance()
		if p.cur.Type != TokLParen {
			return nil, fmt.Errorf("函数 %s 后需要括号", fn)
		}
		p.advance()
		var args []Node
		for p.cur.Type != TokRParen {
			a, err := p.parseExpression(0)
			if err != nil {
				return nil, err
			}
			args = append(args, a)
			if p.cur.Type == TokComma {
				p.advance()
			}
		}
		p.advance() // 消费 ')'
		return &callNode{fn: fn, args: args}, nil
	}
	return nil, fmt.Errorf("意外的 token: %v", p.cur)
}

2.4 Calculator 工具封装

// internal/tools/calc/calculator.go
package calc

import (
	"context"
	"fmt"

	"github.com/yourname/agentforge/internal/registry"
)

type Calculator struct{}

func (c *Calculator) Metadata() registry.ToolMetadata {
	return registry.ToolMetadata{
		Name:        "calculate",
		Group:       "math",
		Description: "执行数学表达式计算。支持四则运算(+ - * /)、幂运算(^)、括号,以及函数: sqrt, sin, cos, log(以10为底), ln, abs。示例: '3.14*(2+8)/5', 'sqrt(16)', '2^10', 'log(100)'。",
		Parameters: map[string]registry.ParamSpec{
			"expression": {
				Type:        "string",
				Description: "数学表达式,如 '3.14*(2+8)/5'",
			},
		},
		Required: []string{"expression"},
		Timeout:  5,
	}
}

func (c *Calculator) Execute(ctx context.Context, args map[string]interface{}) (registry.Result, error) {
	expr, ok := args["expression"].(string)
	if !ok || expr == "" {
		return registry.Result{}, fmt.Errorf("参数 expression 不能为空")
	}

	// 长度上限,防 DoS
	if len(expr) > 500 {
		return registry.Result{}, fmt.Errorf("表达式过长(>500 字符)")
	}

	node, err := Parse(expr)
	if err != nil {
		return registry.Result{}, fmt.Errorf("表达式解析失败: %w", err)
	}
	result, err := node.Eval()
	if err != nil {
		return registry.Result{}, fmt.Errorf("计算失败: %w", err)
	}

	// 智能格式化:整数就不显示小数点
	out := fmt.Sprintf("%v", result)
	if result == float64(int64(result)) && result < 1e15 {
		out = fmt.Sprintf("%d", int64(result))
	}
	return registry.Result{Content: fmt.Sprintf("%s = %s", expr, out)}, nil
}

安全要点回顾:白名单函数(switch 里只允许 6 个数学函数);长度上限 500;除零保护;没有任何「执行任意代码」的路径。


三、沙箱代码执行:最危险也最强大的工具

让 Agent 自己写代码并执行,是「Super Agent」的标配能力,但风险也最高——一个没防护的代码执行工具,能被 LLM 用来删库、读密钥、发起网络攻击。

3.1 安全模型

Go

Python

Sandbox 沙箱约束

超时: 10s

内存: 256MB

网络: 可禁用

文件系统: 临时目录

Agent 生成代码

语言判断

临时文件 + go run

临时文件 + python3

Sandbox

捕获 stdout/stderr

Agent

我们用 os/exec 启动子进程,靠操作系统的进程隔离做沙箱。完整 OS 级沙箱(容器/seccomp)超出本篇范围,但下面这些约束已经能挡住绝大多数意外。

3.2 CodeExecutor 实现

// internal/tools/exec/executor.go
package exec

import (
	"bytes"
	"context"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"time"

	pkgexec "github.com/yourname/agentforge/internal/tools/exec"
	registrypkg "github.com/yourname/agentforge/internal/registry"
)

// 为了不和标准库 exec 冲突,包名用 toolcode
package toolcode

type CodeExecutor struct {
	workDir     string // 工作目录(沙箱根)
	timeoutSec  int
	allowNet    bool
	maxOutputKB int
}

func NewCodeExecutor(workDir string) *CodeExecutor {
	return &CodeExecutor{
		workDir:     workDir,
		timeoutSec:  10,
		allowNet:    false,
		maxOutputKB: 32, // 32KB 输出上限,防刷屏
	}
}

func (c *CodeExecutor) Metadata() registrypkg.ToolMetadata {
	return registrypkg.ToolMetadata{
		Name:        "execute_code",
		Group:       "exec",
		Description: "执行一段代码并返回输出。支持 python3。代码在受限沙箱中运行(超时 10s, 无网络, 临时目录)。适用于需要复杂计算、数据处理、生成文本的场景。不要用它做文件删除或系统操作。",
		Parameters: map[string]registrypkg.ParamSpec{
			"language": {
				Type:        "string",
				Description: "编程语言,目前支持 'python'",
				Enum:        []string{"python"},
			},
			"code": {
				Type:        "string",
				Description: "要执行的代码,完整可运行",
			},
		},
		Required:      []string{"language", "code"},
		Timeout:       10,
		Dangerous:     true,
		AllowedAgents: []string{"admin_agent"}, // 默认只给管理员 Agent
	}
}

func (c *CodeExecutor) Execute(ctx context.Context, args map[string]interface{}) (registrypkg.Result, error) {
	lang, _ := args["language"].(string)
	code, _ := args["code"].(string)
	if code == "" {
		return registrypkg.Result{}, fmt.Errorf("code 不能为空")
	}

	// 代码长度上限
	if len(code) > 10000 {
		return registrypkg.Result{}, fmt.Errorf("代码过长(>10000 字符)")
	}

	switch lang {
	case "python":
		return c.runPython(ctx, code)
	default:
		return registrypkg.Result{}, fmt.Errorf("不支持的语言: %s", lang)
	}
}

func (c *CodeExecutor) runPython(ctx context.Context, code string) (registrypkg.Result, error) {
	// 1. 每次创建独立的临时子目录,隔离不同执行
	runDir, err := os.MkdirTemp(c.workDir, "run_*")
	if err != nil {
		return registrypkg.Result{}, fmt.Errorf("创建临时目录失败: %w", err)
	}
	defer os.RemoveAll(runDir) // 执行完清理

	scriptPath := filepath.Join(runDir, "main.py")
	if err := os.WriteFile(scriptPath, []byte(code), 0644); err != nil {
		return registrypkg.Result{}, err
	}

	// 2. 构建 python3 命令
	//    -B: 不生成 .pyc
	//    -I: 隔离模式,不读 PYTHONPATH/PYTHONHOME
	cmd := exec.CommandContext(ctx, "python3", "-BI", scriptPath)
	cmd.Dir = runDir // 工作目录设为沙箱

	// 3. 网络禁用(通过环境变量 + 系统代理,生产环境建议用 namespace/firewall)
	env := []string{"PATH=/usr/bin:/bin"}
	if !c.allowNet {
		// 设置无效代理,阻断大多数网络库的默认连接
		env = append(env, "http_proxy=", "https_proxy=", "no_proxy=")
	}
	cmd.Env = env

	// 4. 捕获输出
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr

	// 5. 超时控制
	runCtx, cancel := context.WithTimeout(ctx, time.Duration(c.timeoutSec)*time.Second)
	defer cancel()
	cmd.Cancel = cancel // Go 1.20+: 超时发 SIGKILL

	err = cmd.Run()

	// 6. 输出截断
	out := truncateOutput(stdout.String(), c.maxOutputKB)
	errOut := truncateOutput(stderr.String(), c.maxOutputKB)

	// 7. 组装结果
	result := registrypkg.Result{Content: out}
	if err != nil && errOut != "" {
		result.Content = fmt.Sprintf("%s\n\n[stderr]\n%s", out, errOut)
	}

	if ctx.Err() == context.DeadlineExceeded {
		return registrypkg.Result{}, fmt.Errorf("代码执行超时(%ds)", c.timeoutSec)
	}
	return result, nil
}

func truncateOutput(s string, maxKB int) string {
	max := maxKB * 1024
	if len(s) <= max {
		return s
	}
	return s[:max] + fmt.Sprintf("\n... [输出已截断, 原始 %d 字节]", len(s))
}

// 避免未使用 import 报错(演示用)
var _ = runtime.NumCPU

五道防线:超时(context.WithTimeout)、独立临时目录(MkdirTemp + defer RemoveAll)、隔离模式(python3 -BI)、网络阻断(无效代理环境变量)、输出截断(防 stdout 爆炸)。生产环境还应叠加 ulimit -f(限制文件写入)、seccomp 过滤系统调用、或直接放 Docker 容器里。


四、文件读写工具:白名单目录 + 路径穿越防御

Agent 经常需要读写文件(看日志、写报告)。最大的风险是路径穿越攻击——LLM 传入 ../../../etc/passwd 读到不该读的东西。

4.1 FileTools 实现

// internal/tools/fs/filetools.go
package fs

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"strings"

	"github.com/yourname/agentforge/internal/registry"
)

// FileTools 文件读写工具,限定在白名单根目录内
type FileTools struct {
	rootDir string // 沙箱根目录,所有读写只能在此内
	maxSize int64  // 单文件读写上限
}

func NewFileTools(rootDir string) *FileTools {
	abs, _ := filepath.Abs(rootDir)
	return &FileTools{rootDir: abs, maxSize: 1 << 20} // 1MB
}

// safePath 确保目标路径在 rootDir 内,防御路径穿越
func (f *FileTools) safePath(relPath string) (string, error) {
	// Clean 会消除 .., 但还需 Abs + HasPrefix 双重校验
	full := filepath.Join(f.rootDir, relPath)
	abs, err := filepath.Abs(full)
	if err != nil {
		return "", err
	}
	// 关键: 用 HasPrefix 确保最终路径在根目录下
	if !strings.HasPrefix(abs, f.rootDir+string(filepath.Separator)) &&
		abs != f.rootDir {
		return "", fmt.Errorf("拒绝访问沙箱外路径: %s", relPath)
	}
	return abs, nil
}

func (f *FileTools) Metadata() registry.ToolMetadata {
	return registry.ToolMetadata{
		Name:        "read_file",
		Group:       "fs",
		Description: "读取沙箱目录内的文本文件内容。只能访问 Agent 工作目录内的文件。",
		Parameters: map[string]registry.ParamSpec{
			"path": {
				Type:        "string",
				Description: "相对工作目录的文件路径,如 'report.txt'",
			},
		},
		Required: []string{"path"},
		Timeout:  5,
	}
}

func (f *FileTools) Execute(ctx context.Context, args map[string]interface{}) (registry.Result, error) {
	relPath, _ := args["path"].(string)
	if relPath == "" {
		return registry.Result{}, fmt.Errorf("path 不能为空")
	}

	safe, err := f.safePath(relPath)
	if err != nil {
		return registry.Result{}, err // 路径穿越被拦截
	}

	info, err := os.Stat(safe)
	if err != nil {
		return registry.Result{}, fmt.Errorf("文件不存在或不可读: %w", err)
	}
	if info.Size() > f.maxSize {
		return registry.Result{}, fmt.Errorf("文件过大(>%d 字节)", f.maxSize)
	}

	content, err := os.ReadFile(safe)
	if err != nil {
		return registry.Result{}, err
	}
	return registry.Result{Content: string(content)}, nil
}

防御要点filepath.Clean 会折叠 ..,但单独用不够(某些边界 case 仍可绕过)。必须 filepath.Abs + HasPrefix 双保险,确保最终绝对路径一定以根目录为前缀。

4.2 写文件工具(类似结构,省略详细代码)

// WriteFile 工具元数据(Metadata 类似,参数多一个 content)
func (f *FileTools) MetadataWrite() registry.ToolMetadata {
	return registry.ToolMetadata{
		Name:        "write_file",
		Group:       "fs",
		Description: "将文本内容写入沙箱目录内的文件。若文件已存在则覆盖。",
		Parameters: map[string]registry.ParamSpec{
			"path":    {Type: "string", Description: "相对工作目录的文件路径"},
			"content": {Type: "string", Description: "要写入的文本内容"},
		},
		Required: []string{"path", "content"},
		Timeout:  5,
	}
}

五、通用 HTTP 调用器:Agent 的「万能接口」

当目标 API 没有专门工具时,给 Agent 一个通用 HTTP 工具,它就能调任意 REST API。这极大扩展了 Agent 的能力边界。

5.1 HTTPClient 工具

// internal/tools/http/client.go
package httpclient

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"net/http"
	"strings"
	"time"

	"github.com/yourname/agentforge/internal/registry"
)

type HTTPClient struct {
	client     *http.Client
	hostWhitelist []string // 允许访问的主机白名单,空表示不限制
	maxBodyKB  int
}

func NewHTTPClient() *HTTPClient {
	return &HTTPClient{
		client: &http.Client{Timeout: 15 * time.Second},
		maxBodyKB: 64,
	}
}

func (h *HTTPClient) Metadata() registry.ToolMetadata {
	return registry.ToolMetadata{
		Name:        "http_request",
		Group:       "net",
		Description: "发起 HTTP 请求并返回响应体。用于调用 REST API、抓取网页。支持 GET/POST/PUT/DELETE 方法。",
		Parameters: map[string]registry.ParamSpec{
			"method": {
				Type:        "string",
				Description: "HTTP 方法",
				Enum:        []string{"GET", "POST", "PUT", "DELETE"},
				Default:     "GET",
			},
			"url": {
				Type:        "string",
				Description: "完整的 URL,必须含 http:// 或 https://",
			},
			"body": {
				Type:        "string",
				Description: "请求体(POST/PUT 时使用),通常为 JSON 字符串",
			},
			"headers": {
				Type:        "object",
				Description: "请求头键值对",
			},
		},
		Required:  []string{"url"},
		Timeout:   15,
		Dangerous: true,
	}
}

func (h *HTTPClient) Execute(ctx context.Context, args map[string]interface{}) (registry.Result, error) {
	method, _ := args["method"].(string)
	if method == "" {
		method = "GET"
	}
	url, _ := args["url"].(string)
	if url == "" || (!strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://")) {
		return registry.Result{}, fmt.Errorf("url 非法")
	}

	// 主机白名单校验(可选)
	if len(h.hostWhitelist) > 0 {
		if !h.hostAllowed(url) {
			return registry.Result{}, fmt.Errorf("主机不在白名单内")
		}
	}

	var bodyReader io.Reader
	if body, ok := args["body"].(string); ok && body != "" {
		bodyReader = bytes.NewReader([]byte(body))
	}

	req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
	if err != nil {
		return registry.Result{}, err
	}
	// 注入 headers
	if headers, ok := args["headers"].(map[string]interface{}); ok {
		for k, v := range headers {
			req.Header.Set(k, fmt.Sprintf("%v", v))
		}
	}

	resp, err := h.client.Do(req)
	if err != nil {
		return registry.Result{}, fmt.Errorf("请求失败: %w", err)
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(io.LimitReader(resp.Body, int64(h.maxBodyKB*1024)))
	if err != nil {
		return registry.Result{}, err
	}

	content := fmt.Sprintf("HTTP %d\n%s\n\n%s",
		resp.StatusCode, formatHeaders(resp.Header), string(respBody))
	return registry.Result{
		Content:  content,
		MimeType: "text/plain",
		Metadata: map[string]interface{}{
			"status_code": resp.StatusCode,
			"url":         url,
		},
	}, nil
}

func (h *HTTPClient) hostAllowed(urlStr string) bool {
	for _, host := range h.hostWhitelist {
		if strings.Contains(urlStr, host) {
			return true
		}
	}
	return false
}

func formatHeaders(h http.Header) string {
	var b strings.Builder
	for k, vs := range h {
		fmt.Fprintf(&b, "%s: %s\n", k, strings.Join(vs, ", "))
	}
	return b.String()
}

六、工具集一览与注册

把 5 个工具汇总,看它们如何注册进第 08 篇的 Registry:

内置工具集

search 搜索

search_web

Bing/SerpAPI/Mock

math 数学

calculate

Lexer + Pratt 解析

白名单函数

exec 执行

execute_code

python3 沙箱

超时+隔离+截断

fs 文件

read_file

write_file

路径穿越防御

net 网络

http_request

通用 REST 调用

主机白名单

// 注册所有内置工具
func RegisterBuiltins(reg *registry.ToolRegistry, cfg Config) {
	// 搜索(生产用 SerpAPI,开发用 Mock)
	var searchProvider search.SearchProvider
	if cfg.SearchAPIKey != "" {
		searchProvider = search.NewSerpAPI(cfg.SearchAPIKey)
	} else {
		searchProvider = &search.MockProvider{}
	}
	reg.MustRegister(search.NewWebSearch(searchProvider))

	// 计算
	reg.MustRegister(&calc.Calculator{})

	// 代码执行(高危,只给 admin_agent)
	reg.MustRegister(toolcode.NewCodeExecutor(cfg.WorkDir))

	// 文件读写
	fsTool := fs.NewFileTools(cfg.WorkDir)
	reg.MustRegister(fsTool)             // read_file
	reg.MustRegister(fs.NewWriteFile(fsTool)) // write_file(包装)

	// HTTP
	reg.MustRegister(httpclient.NewHTTPClient())
}

本篇小结

工具核心能力关键安全设计
search_web互联网搜索SearchProvider 抽象 + 结果数上限
calculate数学表达式求值Lexer + Pratt 解析器(无 eval)、函数白名单、长度上限
execute_code执行 Python 代码临时目录隔离、超时、-BI 隔离模式、网络阻断、输出截断
read_file/write_file沙箱内文件读写filepath.Abs + HasPrefix 双重路径穿越防御、大小上限
http_request通用 REST 调用方法白名单、主机白名单、响应体大小限制

一条贯穿全篇的原则:Agent 调用的每一个工具,都必须假设「LLM 可能传入恶意或离谱参数」。 防御性编程不是可选的,是工具能上生产的门槛。


下篇预告

第 10 篇:工具选择策略 — 让 Agent 选对工具
工具变多之后,新问题来了:Agent 经常选错工具、或者干脆「选择困难」。下一篇我们分析工具数量与选择准确率的关系,讲工具描述优化、动态工具加载、RAG 辅助发现等提升选择准确率的策略。


如果本篇内容对你有帮助,欢迎点赞收藏!有任何疑问,欢迎在评论区交流。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐