第09篇-常用内置工具集实现-搜索计算代码执行
【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 设计要点
关键设计是抽象出 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) / 5、sin(1.57)、log(100)。我们不能用任何形式的「字符串直接执行」——那等于把代码注入漏洞拱手送给 LLM。
2.1 正确做法:词法分析 + Pratt 解析器
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 安全模型
我们用 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:
// 注册所有内置工具
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 辅助发现等提升选择准确率的策略。
如果本篇内容对你有帮助,欢迎点赞收藏!有任何疑问,欢迎在评论区交流。
更多推荐



所有评论(0)