Go + open ai 实现一个 mini manus

核心概念:

  1. 用户输入 (User Input): 整个流程的起点。
  2. 规划器 (Planner - LLM 1, 例如 Claude 3.7): 接收用户提示 (prompt),并将其分解为一系列子任务。理想情况下,每个子任务都应指定使用哪个工具以及该工具需要什么输入。
  3. 调度器/执行器 (Scheduler/Executor - LLM 2, 例如 Qwen,或者也可以是基于规则的):
    • 管理任务列表 (待办事项)。
    • 决定接下来执行哪个任务。
    • 如果任务需要工具,它会准备该工具的输入并调用它。
    • 接收来自工具的输出。
    • 根据工具的输出更新任务状态,并可能更新整体计划。
    • 一旦所有必要的任务完成,就为用户综合生成最终答案。
  4. 工具 (Tools): 执行特定操作的函数。这里我们有一个"联网搜索"工具。
  5. OpenAPI 客户端 (OpenAPI Clients): 用于向 LLM API 发出 HTTP 请求的 Go 代码。

Go 中的高级工作流程:

rust 复制代码
用户提示 -> 规划器 (LLM1) -> 结构化计划 (任务列表)
                                     |
                                     V
调度器 (LLM2 / 基于规则) ----- (循环)
  |  1. 从计划中选择下一个任务
  |  2. 如果任务需要工具:
  |     |  a. 准备工具输入
  |     |  b. 调用工具 (例如 WebSearch) -> 工具输出
  |     |  c. 将工具输出提供给调度器 (LLM2) 进行处理/下一步决策
  |  3. 如果任务不需要工具 (例如,由 LLM2 直接进行总结、推理):
  |     |  a. LLM2 执行任务
  |  4. LLM2 更新计划,判断目标是否达成,或确定下一个任务。
  |  (当目标达成时结束循环)
  V
最终答案 -> 用户

Go 项目结构 (简化版):

go 复制代码
/my-agent-app
  /cmd
    /main.go        // 主应用程序入口点
  /pkg
    /llmclient      // 与 LLM API (Claude, Qwen) 交互的客户端
      llmclient.go
    /planner        // 规划阶段的逻辑
      planner.go
    /scheduler      // 调度和执行的逻辑
      scheduler.go
    /tools          // 工具的定义和实现
      tools.go
      websearch.go
    /config         // 配置 (API 密钥等)
      config.go
  go.mod
  go.sum

分步实现细节:

1. pkg/config/config.go

go 复制代码
package config

import (
	"log"
	"os"

	"github.com/joho/godotenv"
)

type Config struct {
	ClaudeAPIKey string
	ClaudeAPIURL string
	QwenAPIKey   string
	QwenAPIURL   string
	// 如果需要,添加其他搜索 API 密钥,例如 SerpAPI
	SerpAPIKey   string
}

var AppConfig Config

func LoadConfig() {
	// 如果存在 .env 文件,则加载它
	err := godotenv.Load()
	if err != nil {
		log.Println("未找到 .env 文件,将依赖环境变量")
	}

	AppConfig = Config{
		ClaudeAPIKey: getEnv("CLAUDE_API_KEY", "your_claude_api_key"),
		ClaudeAPIURL: getEnv("CLAUDE_API_URL", "https://api.anthropic.com/v1/messages"), // 示例
		QwenAPIKey:   getEnv("QWEN_API_KEY", "your_qwen_api_key"),
		QwenAPIURL:   getEnv("QWEN_API_URL", "qwen_api_endpoint_here"), // 替换为实际的 Qwen API
		SerpAPIKey:   getEnv("SERPAPI_API_KEY", "your_serpapi_key"),
	}
}

func getEnv(key, fallback string) string {
	if value, ok := os.LookupEnv(key); ok {
		return value
	}
	log.Printf("环境变量 %s 未设置,将使用回退/默认值。", key)
	return fallback
}

在项目根目录下创建一个 .env 文件:

env 复制代码
CLAUDE_API_KEY=sk-ant-your-claude-key...
QWEN_API_KEY=your_qwen_api_key_or_token...
SERPAPI_API_KEY=your_serpapi_search_key... # 如果使用 SerpAPI 进行搜索

2. pkg/llmclient/llmclient.go 这将是一个通用的客户端。您需要根据 Claude 和 Qwen 的特定请求/响应格式对其进行调整。

go 复制代码
package llmclient

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"time"
)

// GenericLLMRequest 和 GenericLLMResponse 仅为示例。
// 您必须根据 Claude 和 Qwen 的实际 API 规范调整这些结构。

// 类似 Claude messages API 的示例
type ClaudeMessage struct {
	Role    string `json:"role"`
	Content string `json:"content"`
}

type ClaudeRequest struct {
	Model     string          `json:"model"`
	Messages  []ClaudeMessage `json:"messages"`
	MaxTokens int             `json:"max_tokens,omitempty"`
	System    string          `json:"system,omitempty"` // 系统提示
}

type ClaudeContentBlock struct {
	Type string `json:"type"`
	Text string `json:"text"`
}

type ClaudeResponse struct {
	ID      string               `json:"id"`
	Type    string               `json:"type"`
	Role    string               `json:"role"`
	Content []ClaudeContentBlock `json:"content"`
	// 添加其他字段,如 usage, stop_reason 等
}


// 如果 Qwen 的 API 类似,您将需要类似的结构体,或者一个更通用的结构体
// 针对 Qwen (这只是一个猜测,请检查其官方 API 文档)
type QwenInput struct {
    Prompt string `json:"prompt"`
    // 其他参数
}
type QwenRequest struct {
	Model string `json:"model"`
    Input QwenInput `json:"input"`
    // 参数等
}

type QwenChoice struct {
    Text string `json:"text"` // 或者 "message": {"content": "..."}
    // ...
}
type QwenResponse struct {
    Output struct {
        Text string `json:"text"` // 这高度依赖于具体模型
    } `json:"output"`
    // 或者 Choices []QwenChoice `json:"choices"`
    // ...
}


type LLMClient struct {
	httpClient *http.Client
}

func NewLLMClient() *LLMClient {
	return &LLMClient{
		httpClient: &http.Client{Timeout: 90 * time.Second},
	}
}

// CallClaudeAPI - 专用于 Claude Messages API
func (c *LLMClient) CallClaudeAPI(apiKey, apiURL, modelName, systemPrompt string, messages []ClaudeMessage, maxTokens int) (string, error) {
	claudeReq := ClaudeRequest{
		Model:     modelName,
		Messages:  messages,
		MaxTokens: maxTokens,
		System:    systemPrompt,
	}

	reqBody, err := json.Marshal(claudeReq)
	if err != nil {
		return "", fmt.Errorf("序列化 Claude 请求失败: %w", err)
	}

	req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(reqBody))
	if err != nil {
		return "", fmt.Errorf("创建 Claude 请求失败: %w", err)
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("x-api-key", apiKey)
	req.Header.Set("anthropic-version", "2023-06-01") // Claude API 要求

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("调用 Claude API 失败: %w", err)
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", fmt.Errorf("读取 Claude 响应体失败: %w", err)
	}

	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("Claude API 请求失败,状态码 %d: %s", resp.StatusCode, string(respBody))
	}

	var claudeResp ClaudeResponse
	if err := json.Unmarshal(respBody, &claudeResp); err != nil {
		return "", fmt.Errorf("反序列化 Claude 响应失败: %w. 响应体: %s", err, string(respBody))
	}

	if len(claudeResp.Content) > 0 && claudeResp.Content[0].Type == "text" {
		return claudeResp.Content[0].Text, nil
	}
	return "", errors.New("在 Claude 响应中未找到文本内容")
}


// CallQwenAPI - 根据 Qwen 的特定 API 结构进行调整
func (c *LLMClient) CallQwenAPI(apiKey, apiURL, modelName, prompt string) (string, error) {
    // 这是一个非常通用的占位符 - 需要 Qwen API 的详细信息
    // 使用假设的 Qwen API 结构的示例
    qwenReqPayload := QwenRequest{
        Model: modelName, // 例如 "qwen-turbo"
        Input: QwenInput{Prompt: prompt},
        // Parameters: {"temperature": 0.7}, // 示例
    }

    reqBody, err := json.Marshal(qwenReqPayload)
    if err != nil {
        return "", fmt.Errorf("序列化 Qwen 请求失败: %w", err)
    }

    req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(reqBody))
    if err != nil {
        return "", fmt.Errorf("创建 Qwen 请求失败: %w", err)
    }

    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", "Bearer "+apiKey) // 许多 API 的通用做法

    resp, err := c.httpClient.Do(req)
    if err != nil {
        return "", fmt.Errorf("调用 Qwen API 失败: %w", err)
    }
    defer resp.Body.Close()

    respBody, err := io.ReadAll(resp.Body)
    if err != nil {
        return "", fmt.Errorf("读取 Qwen 响应体失败: %w", err)
    }

    if resp.StatusCode != http.StatusOK {
        return "", fmt.Errorf("Qwen API 请求失败,状态码 %d: %s", resp.StatusCode, string(respBody))
    }

    var qwenResp QwenResponse
    if err := json.Unmarshal(respBody, &qwenResp); err != nil {
        // 如果反序列化失败,尝试打印原始响应体以进行调试
        return "", fmt.Errorf("反序列化 Qwen 响应失败: %w. 原始响应体: %s", err, string(respBody))
    }
    
    // 根据 Qwen 的 API 结构提取实际的文本响应
    // 这高度依赖于 Qwen API
    if qwenResp.Output.Text != "" {
         return qwenResp.Output.Text, nil
    }
    // if len(qwenResp.Choices) > 0 {
    //  return qwenResp.Choices[0].Text, nil // 或者 qwenResp.Choices[0].Message.Content
    // }

    return "", errors.New("在 Qwen 响应中未找到文本内容或格式无法识别")
}

3. pkg/tools/tools.gopkg/tools/websearch.go

go 复制代码
// pkg/tools/tools.go
package tools

import "fmt"

const (
	ToolWebSearch = "web_search"
)

// ToolInput 代表任何工具的输入
type ToolInput map[string]interface{}

// ToolOutput 代表任何工具的输出
type ToolOutput struct {
	Result string
	Err    error
}

// Tool 代表一个可执行的工具
type Tool interface {
	Name() string
	Description() string // 供 LLM 理解该工具的作用
	Execute(input ToolInput) ToolOutput
}

// ToolRegistry 持有可用的工具
type ToolRegistry struct {
	tools map[string]Tool
}

func NewToolRegistry() *ToolRegistry {
	return &ToolRegistry{tools: make(map[string]Tool)}
}

func (r *ToolRegistry) Register(tool Tool) {
	r.tools[tool.Name()] = tool
}

func (r *ToolRegistry) GetTool(name string) (Tool, bool) {
	tool, ok := r.tools[name]
	return tool, ok
}

func (r *ToolRegistry) GetAvailableToolsDescription() string {
	desc := "可用工具:\n"
	for _, tool := range r.tools {
		desc += fmt.Sprintf("- 名称: %s, 描述: %s\n", tool.Name(), tool.Description())
	}
	return desc
}
go 复制代码
// pkg/tools/websearch.go
package tools

import (
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"strings"

	"your_project_path/my-agent-app/pkg/config" // 调整路径
)

type WebSearchTool struct{}

func (t *WebSearchTool) Name() string {
	return ToolWebSearch
}

func (t *WebSearchTool) Description() string {
	return "使用 SerpApi 执行网络搜索以查找在线信息。输入应为一个 JSON 对象,其中包含 'query' 字段,例如 {\"query\": \"最新 AI 新闻\"}。"
}

// SerpAPIResult 代表搜索结果的简化结构
type SerpAPIOrganicResult struct {
	Title   string `json:"title"`
	Link    string `json:"link"`
	Snippet string `json:"snippet"`
}
type SerpAPIResponse struct {
	OrganicResults []SerpAPIOrganicResult `json:"organic_results"`
	AnswerBox      struct {
		Answer  string `json:"answer"`
		Snippet string `json:"snippet"`
		Title   string `json:"title"`
	} `json:"answer_box"`
	Error string `json:"error"`
}

func (t *WebSearchTool) Execute(input ToolInput) ToolOutput {
	query, ok := input["query"].(string)
	if !ok || query == "" {
		return ToolOutput{Err: fmt.Errorf("web_search 工具需要在输入中提供 'query' 字符串")}
	}

	log.Printf("WebSearchTool:正在执行查询:%s\n", query)

	// 以 SerpAPI 为例。您可以将其替换为任何搜索提供商。
	// 确保 SERPAPI_API_KEY 在您的 .env 文件或环境变量中
	apiKey := config.AppConfig.SerpAPIKey
	if apiKey == "" || apiKey == "your_serpapi_key" {
		log.Println("SERPAPI_API_KEY 未配置。网络搜索将被跳过/模拟。")
		return ToolOutput{Result: fmt.Sprintf("模拟搜索结果:%s。(SERPAPI_API_KEY 未设置)", query)}
	}

	searchURL := fmt.Sprintf("https://serpapi.com/search.json?q=%s&api_key=%s", url.QueryEscape(query), apiKey)

	resp, err := http.Get(searchURL)
	if err != nil {
		return ToolOutput{Err: fmt.Errorf("执行网络搜索 GET 请求失败:%w", err)}
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		bodyBytes, _ := io.ReadAll(resp.Body)
		return ToolOutput{Err: fmt.Errorf("网络搜索 API 返回状态 %d:%s", resp.StatusCode, string(bodyBytes))}
	}

	var serpResp SerpAPIResponse
	if err := json.NewDecoder(resp.Body).Decode(&serpResp); err != nil {
		return ToolOutput{Err: fmt.Errorf("解码网络搜索 API 响应失败:%w", err)}
	}

	if serpResp.Error != "" {
		return ToolOutput{Err: fmt.Errorf("网络搜索 API 错误:%s", serpResp.Error)}
	}
	
	var resultsText strings.Builder
	if serpResp.AnswerBox.Answer != "" {
		resultsText.WriteString(fmt.Sprintf("答案框:%s\n%s\n\n", serpResp.AnswerBox.Title, serpResp.AnswerBox.Snippet))
	}

	for i, item := range serpResp.OrganicResults {
		if i >= 3 { // 限制为前3个结果以保持简洁
			break
		}
		resultsText.WriteString(fmt.Sprintf("标题:%s\n链接:%s\n摘要:%s\n\n", item.Title, item.Link, item.Snippet))
	}
    if resultsText.Len() == 0 {
        return ToolOutput{Result: "未找到相关搜索结果。"}
    }
	return ToolOutput{Result: resultsText.String()}
}

4. pkg/planner/planner.go

go 复制代码
package planner

import (
	"encoding/json"
	"fmt"
	"log"

	"your_project_path/my-agent-app/pkg/config"     // 调整路径
	"your_project_path/my-agent-app/pkg/llmclient" // 调整路径
	"your_project_path/my-agent-app/pkg/tools"     // 调整路径
)

// Task 代表计划中的单个任务
type Task struct {
	ID          int                    `json:"id"`
	Description string                 `json:"description"`
	ToolName    string                 `json:"tool_name,omitempty"` // 例如 "web_search"
	ToolInput   map[string]interface{} `json:"tool_input,omitempty"` // 例如 {"query": "某个搜索词"}
	Status      string                 `json:"status"`               // "todo" (待办), "in_progress" (进行中), "done" (已完成), "error" (错误)
	Result      string                 `json:"result,omitempty"`     // 工具或 LLM 的输出
	DependsOn   []int                  `json:"depends_on,omitempty"` // 必须首先完成的任务的 ID
}

// Plan 是任务列表
type Plan struct {
	Tasks []Task `json:"tasks"`
}

type Planner struct {
	llm    *llmclient.LLMClient
	claudeConfig config.Config // 用于访问 API 密钥和 URL
	toolRegistry *tools.ToolRegistry
}

func NewPlanner(llm *llmclient.LLMClient, cfg config.Config, tr *tools.ToolRegistry) *Planner {
	return &Planner{llm: llm, claudeConfig: cfg, toolRegistry: tr}
}

func (p *Planner) CreatePlan(userPrompt string) (*Plan, error) {
	systemPrompt := `
你是一个专家级的规划代理。你的目标是将用户的请求分解为一系列可操作的子任务。
对于每个任务,你必须:
1. 提供一个清晰的 "description" (描述) 说明需要做什么。
2. 如果需要工具,指定 "tool_name" (工具名称)。
3. 如果指定了工具,以 JSON 对象的形式提供必要的 "tool_input" (工具输入)。
4. 如果一个任务依赖于先前任务的输出,用 "depends_on" (任务 ID 列表) 来指明。第一个任务的 ID 为 1。

可用工具:
` + p.toolRegistry.GetAvailableToolsDescription() + `

如果任务是关于推理、总结或基于先前信息回答问题而不需要特定工具,则不要指定 "tool_name"。执行器 LLM 将处理它。

将计划输出为一个 JSON 对象,该对象有一个名为 "tasks" 的键,其值为一个任务对象数组。每个任务对象应包含 "id"、"description"、"tool_name" (可选)、"tool_input" (如果无 tool_name 则可选) 和 "depends_on" (可选)。
任务 ID 从 1 开始。
网络搜索任务示例:
{
  "tasks": [
    {
      "id": 1,
      "description": "搜索伦敦当前的天气。",
      "tool_name": "web_search",
      "tool_input": {"query": "伦敦当前天气"}
    },
    {
      "id": 2,
      "description": "总结搜索结果。",
      "depends_on": [1]
      // 没有 tool_name,意味着执行器 LLM 将根据任务1的结果进行总结
    }
  ]
}
`
	messages := []llmclient.ClaudeMessage{
		{Role: "user", Content: userPrompt},
	}

	// 使用 Claude Opus 进行规划,根据需要调整模型
	// 确保 CLAUDE_API_URL 和 CLAUDE_API_KEY 已正确设置
	// Max tokens 可能需要调整
	rawPlan, err := p.llm.CallClaudeAPI(
		p.claudeConfig.ClaudeAPIKey,
		p.claudeConfig.ClaudeAPIURL,
		"claude-3-opus-20240229", // 或者 "claude-3-5-sonnet-20240620"
		systemPrompt,
		messages,
		2048, // 计划的 Max tokens
	)
	if err != nil {
		return nil, fmt.Errorf("规划器 LLM 调用失败: %w", err)
	}

	log.Printf("从 LLM 获取的原始计划:\n%s\n", rawPlan)

	var plan Plan
	// LLM 可能直接返回 JSON,或者返回包含 JSON 的文本。
	// 如果 JSON 被文本或 markdown 包裹,尝试提取它。
	startIndex := -1
	endIndex := -1
	for i, r := range rawPlan {
		if r == '{' && startIndex == -1 { // 查找第一个 {
			// 检查它是否是 "tasks" 对应整个 JSON 对象的开始
			if i+8 < len(rawPlan) && rawPlan[i:i+8] == "{\"tasks\"" {
				startIndex = i
			}
		}
		if r == '}' { // 查找最后一个 }
			// 检查它是否是主对象内 "tasks" 数组的结束括号
			// 这有点棘手;一个更健壮的解析器或更受约束的 LLM 输出会更好。
			// 目前,我们假设如果 LLM 正确开始,它会输出一个干净的 JSON 对象。
			if startIndex != -1 {
				endIndex = i
			}
		}
	}

	var jsonStr string
	if startIndex != -1 && endIndex != -1 && startIndex < endIndex {
		jsonStr = rawPlan[startIndex : endIndex+1]
	} else {
		// 如果未找到清晰的 JSON 对象标记,则回退,假设 rawPlan 就是 JSON 字符串
		log.Println("未能找到清晰的 JSON 对象标记,尝试直接解析 rawPlan。")
		jsonStr = rawPlan
	}


	if err := json.Unmarshal([]byte(jsonStr), &plan); err != nil {
		return nil, fmt.Errorf("从 LLM 输出反序列化计划失败:%w。原始输出:%s", err, rawPlan)
	}

	// 初始化所有任务的状态
	for i := range plan.Tasks {
		plan.Tasks[i].Status = "todo"
	}

	return &plan, nil
}

5. pkg/scheduler/scheduler.go

go 复制代码
package scheduler

import (
	"encoding/json"
	"fmt"
	"log"
	"strings"

	"your_project_path/my-agent-app/pkg/config"     // 调整路径
	"your_project_path/my-agent-app/pkg/llmclient" // 调整路径
	"your_project_path/my-agent-app/pkg/planner"   // 调整路径
	"your_project_path/my-agent-app/pkg/tools"     // 调整路径
)

type Scheduler struct {
	llm          *llmclient.LLMClient
	qwenConfig   config.Config // 用于访问 Qwen API 密钥和 URL
	toolRegistry *tools.ToolRegistry
}

func NewScheduler(llm *llmclient.LLMClient, cfg config.Config, tr *tools.ToolRegistry) *Scheduler {
	return &Scheduler{llm: llm, qwenConfig: cfg, toolRegistry: tr}
}

func (s *Scheduler) ExecutePlan(userPrompt string, plan *planner.Plan) (string, error) {
	maxIterations := 10 // 循环的安全中断
	iteration := 0

	// 提供给 Qwen (或执行器 LLM) 的上下文
	var executionContext []string
	executionContext = append(executionContext, fmt.Sprintf("原始用户请求:%s", userPrompt))

	for iteration < maxIterations {
		iteration++
		log.Printf("\n--- 迭代 %d ---\n", iteration)

		nextTask, taskIndex := s.findNextTask(plan)
		if nextTask == nil {
			log.Println("没有更多任务可执行或所有依赖项均已满足。")
			break // 所有任务已完成或卡住
		}

		log.Printf("正在执行任务 ID %d:%s\n", nextTask.ID, nextTask.Description)
		plan.Tasks[taskIndex].Status = "in_progress"

		var taskResult string
		var taskErr error

		if nextTask.ToolName != "" {
			tool, ok := s.toolRegistry.GetTool(nextTask.ToolName)
			if !ok {
				taskErr = fmt.Errorf("任务 ID %d 的工具 '%s' 未找到", nextTask.ToolName, nextTask.ID)
				taskResult = fmt.Sprintf("错误:%s", taskErr.Error())
			} else {
				// 准备工具输入,可能使用依赖任务的结果
				toolInput := nextTask.ToolInput
				if toolInput == nil {
					toolInput = make(map[string]interface{})
				}

				// 基本的依赖注入 (非常简化)
				// 一个更健壮的系统会将先前任务的特定输出映射到当前任务的输入
				for _, depID := range nextTask.DependsOn {
					depTask, found := s.findTaskByID(plan, depID)
					if found && depTask.Status == "done" && depTask.Result != "" {
						// 简单:将所有依赖结果添加到工具 (或LLM) 的上下文中
						// 更复杂:输出到输入的特定映射
						toolInput[fmt.Sprintf("dependency_result_task_%d", depID)] = depTask.Result
					}
				}
				
				log.Printf("调用工具 '%s',输入为:%+v\n", nextTask.ToolName, toolInput)
				output := tool.Execute(toolInput)
				taskResult = output.Result
				taskErr = output.Err
			}
		} else {
			// 由执行器 LLM (Qwen) 处理的任务
			// 根据任务描述和上下文构建 Qwen 的提示
			qwenPrompt := s.buildQwenPromptForTask(userPrompt, plan, nextTask, executionContext)
			log.Printf("请求 Qwen 执行任务:\n%s\n", nextTask.Description)

			// 使用 Qwen API (确保 QWEN_API_URL 和 QWEN_API_KEY 正确)
			// 根据需要调整模型名称
			qwenResult, err := s.llm.CallQwenAPI(
				s.qwenConfig.QwenAPIKey,
				s.qwenConfig.QwenAPIURL,
				"qwen-turbo", // 或 "qwen-plus", "qwen-max" 等
				qwenPrompt,
			)
			if err != nil {
				taskErr = fmt.Errorf("任务的 Qwen LLM 调用失败:%w", err)
				taskResult = fmt.Sprintf("错误:%s", taskErr.Error())
			} else {
				taskResult = qwenResult
			}
		}

		if taskErr != nil {
			log.Printf("执行任务 ID %d 时出错:%v\n", nextTask.ID, taskErr)
			plan.Tasks[taskIndex].Status = "error"
			plan.Tasks[taskIndex].Result = taskResult // 将错误消息存储为结果
			// 基本错误处理:停止或允许 LLM 重新规划 (更高级)
			// 目前,我们将让它继续,看看其他分支是否可以完成。
			// 或者,你可以在这里立即返回错误。
		} else {
			log.Printf("任务 ID %d 结果:%s\n", nextTask.ID, taskResult)
			plan.Tasks[taskIndex].Status = "done"
			plan.Tasks[taskIndex].Result = taskResult
		}
		
		executionContext = append(executionContext, fmt.Sprintf("任务 %d (%s) 结果:%s", nextTask.ID, nextTask.Description, taskResult))

		// 检查所有任务是否已完成
		if s.allTasksDone(plan) {
			log.Println("所有任务已完成。")
			break
		}
	}

	// 使用 Qwen (或其他 LLM) 进行最终综合步骤
	finalAnswer, err := s.synthesizeFinalAnswer(userPrompt, plan, executionContext)
	if err != nil {
		return "", fmt.Errorf("综合最终答案失败:%w", err)
	}
	return finalAnswer, nil
}

func (s *Scheduler) findNextTask(plan *planner.Plan) (*planner.Task, int) {
	for i, task := range plan.Tasks {
		if task.Status == "todo" {
			dependenciesMet := true
			for _, depID := range task.DependsOn {
				depTask, found := s.findTaskByID(plan, depID)
				if !found || (depTask.Status != "done" && depTask.Status != "skipped_due_to_error") { // 如果依赖项出错,则允许跳过
					dependenciesMet = false
					break
				}
			}
			if dependenciesMet {
				return &plan.Tasks[i], i // 返回指向切片中任务的指针
			}
		}
	}
	return nil, -1 // 未找到合适的任务
}

func (s *Scheduler) findTaskByID(plan *planner.Plan, id int) (*planner.Task, bool) {
	for i := range plan.Tasks {
		if plan.Tasks[i].ID == id {
			return &plan.Tasks[i], true
		}
	}
	return nil, false
}

func (s *Scheduler) allTasksDone(plan *planner.Plan) bool {
	for _, task := range plan.Tasks {
		if task.Status != "done" && task.Status != "error" && task.Status != "skipped_due_to_error" { // 在此检查中,将错误视为"已完成"的一种形式
			return false
		}
	}
	return true
}

func (s *Scheduler) buildQwenPromptForTask(originalUserPrompt string, plan *planner.Plan, currentTask *planner.Task, history []string) string {
	var sb strings.Builder
	sb.WriteString(fmt.Sprintf("你是一个正在执行计划的 AI 助手。原始用户请求是:\"%s\"\n\n", originalUserPrompt))
	sb.WriteString("当前计划状态:\n")
	for _, t := range plan.Tasks {
		status := t.Status
		if t.ID == currentTask.ID {
			status = "进行中 (当前任务)"
		}
		sb.WriteString(fmt.Sprintf("- 任务 ID %d:%s (状态:%s)\n", t.ID, t.Description, status))
		if t.Status == "done" && t.Result != "" {
			trimmedResult := t.Result
			if len(trimmedResult) > 200 { // 保持上下文简洁
				trimmedResult = trimmedResult[:200] + "..."
			}
			sb.WriteString(fmt.Sprintf("  结果:%s\n", trimmedResult))
		}
	}
	sb.WriteString("\n执行历史 (选定的先前结果):\n")
	for _, h := range history {
		trimmedHistory := h
		if len(trimmedHistory) > 300 {
			trimmedHistory = trimmedHistory[:300] + "..."
		}
		sb.WriteString(fmt.Sprintf("- %s\n", trimmedHistory))
	}

	sb.WriteString(fmt.Sprintf("\n你当前的任务 (ID %d) 是:\"%s\"。\n", currentTask.ID, currentTask.Description))
	
	// 如果有,添加直接依赖项的结果
	if len(currentTask.DependsOn) > 0 {
		sb.WriteString("此任务依赖于以下已完成任务的结果:\n")
		for _, depID := range currentTask.DependsOn {
			depTask, found := s.findTaskByID(plan, depID)
			if found && depTask.Status == "done" && depTask.Result != "" {
				sb.WriteString(fmt.Sprintf("  - 任务 %d 结果:%s\n", depID, depTask.Result))
			}
		}
	}

	sb.WriteString("\n请执行此任务,并仅提供任务描述所要求的直接结果或信息。不要添加对话式的冗余内容。如果任务是总结或分析,请提供该总结/分析。")
	return sb.String()
}


func (s *Scheduler) synthesizeFinalAnswer(userPrompt string, plan *planner.Plan, executionContext []string) (string, error) {
	var sb strings.Builder
	sb.WriteString(fmt.Sprintf("原始用户请求是:\"%s\"\n\n", userPrompt))
	sb.WriteString("执行了以下计划:\n")
	for _, task := range plan.Tasks {
		sb.WriteString(fmt.Sprintf("- 任务 ID %d:%s (状态:%s)\n", task.ID, task.Description, task.Status))
		if task.Result != "" {
			// 为最终提示总结长结果
			resultSummary := task.Result
			if len(resultSummary) > 500 { // 每个任务在最终提示中的结果最多 500 个字符
				resultSummary = resultSummary[:500] + "..."
			}
			sb.WriteString(fmt.Sprintf("  结果:%s\n", resultSummary))
		}
	}
	sb.WriteString("\n根据执行历史和任务结果,为用户的原始请求提供一个全面的最终答案。")
	sb.WriteString("清晰简洁地呈现答案。如果由于错误导致请求的某些部分无法完成,请提及这一点。")

	finalPrompt := sb.String()
	log.Printf("\n--- 综合最终答案 ---\n给 Qwen 的提示:\n%s\n", finalPrompt)

	answer, err := s.llm.CallQwenAPI(
		s.qwenConfig.QwenAPIKey,
		s.qwenConfig.QwenAPIURL,
		"qwen-plus", // 如果可用,使用更强大的模型进行综合
		finalPrompt,
	)
	if err != nil {
		return "", fmt.Errorf("Qwen LLM 最终综合调用失败:%w", err)
	}
	return answer, nil
}

// 辅助函数,将计划编组为字符串以用于日志记录或上下文
func (s *Scheduler) planToString(plan *planner.Plan) string {
    planBytes, err := json.MarshalIndent(plan, "", "  ")
    if err != nil {
        return "编组计划时出错"
    }
    return string(planBytes)
}

6. cmd/main.go

go 复制代码
package main

import (
	"bufio"
	"fmt"
	"log"
	"os"
	"strings"

	"your_project_path/my-agent-app/pkg/config"     // 调整路径
	"your_project_path/my-agent-app/pkg/llmclient" // 调整路径
	"your_project_path/my-agent-app/pkg/planner"   // 调整路径
	"your_project_path/my-agent-app/pkg/scheduler" // 调整路径
	"your_project_path/my-agent-app/pkg/tools"     // 调整路径
)

func main() {
	config.LoadConfig() // 加载 API 密钥和 URL

	// 初始化 LLM 客户端
	llm := llmclient.NewLLMClient()

	// 初始化工具注册表并注册工具
	toolRegistry := tools.NewToolRegistry()
	toolRegistry.Register(&tools.WebSearchTool{})
	// 如果创建了更多工具,请在此处添加

	// 初始化规划器
	taskPlanner := planner.NewPlanner(llm, config.AppConfig, toolRegistry)

	// 初始化调度器
	taskScheduler := scheduler.NewScheduler(llm, config.AppConfig, toolRegistry)

	log.Println("Go Agent 系统已初始化。请输入您的提示 (或输入 'exit' 退出):")

	scanner := bufio.NewScanner(os.Stdin)
	for {
		fmt.Print("> ")
		if !scanner.Scan() {
			break
		}
		userInput := scanner.Text()
		userInput = strings.TrimSpace(userInput)

		if strings.ToLower(userInput) == "exit" {
			log.Println("正在退出。")
			break
		}

		if userInput == "" {
			continue
		}

		log.Println("用户提示:", userInput)

		// 1. 创建计划
		log.Println("--- 规划阶段 ---")
		currentPlan, err := taskPlanner.CreatePlan(userInput)
		if err != nil {
			log.Printf("创建计划时出错:%v\n", err)
			continue
		}
		if currentPlan == nil || len(currentPlan.Tasks) == 0 {
			log.Println("规划器未返回任何任务。")
			fmt.Println("抱歉,我无法为该请求制定计划。")
			continue
		}
		log.Println("生成的计划:")
		for _, task := range currentPlan.Tasks {
			log.Printf("  ID: %d, 描述: %s, 工具: %s, 输入: %v, 依赖: %v\n", task.ID, task.Description, task.ToolName, task.ToolInput, task.DependsOn)
		}

		// 2. 执行计划
		log.Println("--- 执行阶段 ---")
		finalAnswer, err := taskScheduler.ExecutePlan(userInput, currentPlan)
		if err != nil {
			log.Printf("执行计划时出错:%v\n", err)
			fmt.Println("抱歉,处理您的请求时发生错误。")
			continue
		}

		fmt.Println("\n=== 最终答案 ===")
		fmt.Println(finalAnswer)
		fmt.Println("====================")
	}

	if err := scanner.Err(); err != nil {
		log.Fatalf("读取输入时出错:%v", err)
	}
}

运行此项目:

  1. 替换占位符:
    • your_project_path/my-agent-app 替换为你的实际 Go 模块路径。
    • .env 文件中或作为环境变量设置 your_claude_api_keyqwen_api_endpoint_here (Qwen API 的实际端点)、your_qwen_api_key
    • 如果你使用 SerpAPI,在 .env 中设置 SERPAPI_API_KEY
    • 至关重要:使用 Claude 和 Qwen 正确的 API 请求/响应结构更新 llmclient.go。提供的结构仅为示例性。 你需要查阅它们的 API 文档。Qwen 部分尤其具有推测性,你需要找到其确切的 API 格式。
  2. 安装依赖:
    • go get github.com/joho/godotenv
  3. 构建和运行:
    • go mod init your_project_path/my-agent-app (如果尚未执行)
    • go mod tidy
    • go run cmd/main.go

重要考虑因素和改进方向:

  • LLM API 具体细节: llmclient.go 是通用的。你 必须 根据你打算使用的 Claude 和 Qwen API 的确切 OpenAPI 规范来调整请求和响应结构体 (ClaudeRequest, ClaudeResponse, QwenRequest, QwenResponse) 以及请求构建逻辑。这是使其工作的最关键部分。
  • 提示工程 (Prompt Engineering): 计划和执行的质量在很大程度上取决于提供给 Claude (规划器) 和 Qwen (调度器/执行器) 的系统提示。你需要对这些提示进行迭代优化。
  • 错误处理: 当前的错误处理是基础的。你可能需要更复杂的重试机制,或者在任务失败时让 LLM 重新规划的方法。
  • 状态管理: 对于长时间运行的任务或更复杂的交互,你可能需要持久化计划的状态 (例如,存储在数据库中)。
  • 工具输入/输出: 当前的工具输入 (map[string]interface{}) 很灵活但不是类型安全的。对于更复杂的工具,应定义特定的输入/输出结构体。LLM 需要能够为 tool_input 生成正确的 JSON。
  • 依赖管理: DependsOn 逻辑很简单。对于复杂的工作流程,可能需要更健壮的依赖图和执行策略。
  • 上下文窗口: 在构建提示时,尤其是在 executionContextsynthesizeFinalAnswer 提示中,要注意 LLM 的上下文窗口限制。
  • 成本: LLM API 调用可能很昂贵。实施日志记录和监控。
  • 安全: 如果工具可以执行任意代码或访问敏感系统,请务必小心。对输入进行清理。
  • 流式处理 (Streaming): 为了更好的用户体验,可以考虑从 LLM 流式传输响应。

这个全面的指南应该能为您提供一个坚实的基础。关键是从一个部分开始,让它工作起来 (例如,规划器的 LLM 调用),然后逐步构建其他组件。请务必仔细查阅 Claude 和 Qwen 的 API 文档。

相关推荐
保持学习ing2 小时前
SpringBoot前后台交互 -- 登录功能实现(拦截器+异常捕获器)
java·spring boot·后端·ssm·交互·拦截器·异常捕获器
十年老菜鸟2 小时前
spring boot源码和lib分开打包
spring boot·后端·maven
白宇横流学长3 小时前
基于SpringBoot实现的课程答疑系统设计与实现【源码+文档】
java·spring boot·后端
加瓦点灯4 小时前
什么?工作五年还不了解SafePoint?
后端
他日若遂凌云志4 小时前
Lua 模块系统的前世今生:从 module () 到 local _M 的迭代
后端
David爱编程4 小时前
Docker 安全全揭秘:防逃逸、防漏洞、防越权,一篇学会容器防御!
后端·docker·容器
小码编匠5 小时前
WinForm 工业自动化上位机通用框架:注册登录及主界面切换实现
后端·c#·.net
weixin_483745625 小时前
Springboot项目的目录结构
java·后端
阿里云云原生5 小时前
2025年第二届“兴智杯”智能编码创新应用开发挑战赛正式启动
后端
保持学习ing5 小时前
SpringBoot 前后台交互 -- CRUD
java·spring boot·后端·ssm·项目实战·页面放行