259 lines
9.7 KiB
Go
259 lines
9.7 KiB
Go
// Package runner 工具框架的"使用面":ReAct 执行循环。
|
||
// 模型调用按模型响应类型分流:流式走 gateway.ModelCallStream(逐 chunk 推增量),同步/异步走 gateway.ModelCallResult;
|
||
// 本包只保留"模型思考→调用工具→观察结果"的循环编排,工具的"使用"统一走 tools.Default.Call。
|
||
package runner
|
||
|
||
import (
|
||
"ai-agent/gateway"
|
||
"ai-agent/workflow/consts/model"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
|
||
"github.com/gogf/gf/v2/util/gconv"
|
||
|
||
"gitea.redpowerfuture.com/red-future/common/tools"
|
||
)
|
||
|
||
// ReActEventType ReAct 过程事件类型(WebSocket 场景逐条推送,HTTP 同步场景不设置 OnEvent 则忽略)
|
||
type ReActEventType string
|
||
|
||
const (
|
||
ReActEventModelCall ReActEventType = "model_call" // 模型思考(step 开始)
|
||
ReActEventToolCall ReActEventType = "tool_call" // 模型请求调用工具
|
||
ReActEventToolResult ReActEventType = "tool_result" // 工具返回结果
|
||
ReActEventAnswer ReActEventType = "answer" // 最终回答
|
||
ReActEventAnswerChunk ReActEventType = "answer_chunk" // 回答内容增量(逐 chunk)
|
||
ReActEventReasoningChunk ReActEventType = "reasoning_chunk" // 思考内容增量(逐 chunk)
|
||
ReActEventError ReActEventType = "error"
|
||
)
|
||
|
||
// ReActEvent ReAct 过程事件,按发生顺序回调
|
||
type ReActEvent struct {
|
||
Type ReActEventType
|
||
Step int
|
||
MaxStep int
|
||
Description string // 工具用途说明(tool_call/tool_result 推给前端展示,不暴露工具名/参数)
|
||
Answer string
|
||
Delta string // 流式文本增量(ReActEventAnswerChunk / ReActEventReasoningChunk 用)
|
||
Message string
|
||
Error string
|
||
}
|
||
|
||
// ReActAgent 实现 ReAct 模式的智能体:模型思考→调用工具→观察工具结果→重复→最终回答。
|
||
// 工具的"使用"统一走 tools.Default.Call:未知工具由 Server 返回结构化 not_found,
|
||
// 无需在 runner 内维护工具查找表。
|
||
type ReActAgent struct {
|
||
ModelId int64
|
||
SessionId string
|
||
ToolList []*tools.Tool
|
||
SystemPrompt string
|
||
MaxStep int
|
||
// OnEvent 过程回调(WebSocket 流式推送用;HTTP 同步调用不设置,忽略事件)
|
||
OnEvent func(ReActEvent)
|
||
// TotalTokens 本次 ReAct 循环累计 token 消耗(供调用方落库)
|
||
TotalTokens int64
|
||
// TotalCost 本次 ReAct 循环累计费用(元,各步模型调用返回的 cost 求和,供调用方落库)
|
||
TotalCost float64
|
||
}
|
||
|
||
// NewReActAgent 创建 ReAct 智能体
|
||
func NewReActAgent(modelId int64, sessionId string, toolList []*tools.Tool, systemPrompt string, maxStep int) *ReActAgent {
|
||
return &ReActAgent{
|
||
ModelId: modelId,
|
||
SessionId: sessionId,
|
||
ToolList: toolList,
|
||
SystemPrompt: systemPrompt,
|
||
MaxStep: maxStep,
|
||
}
|
||
}
|
||
|
||
// Run 执行 ReAct 循环
|
||
// 标准流程: 思考 → 行动(调用工具) → 观察(工具结果) → 重复 → 最终回答
|
||
func (a *ReActAgent) Run(ctx context.Context, userInput string) (string, error) {
|
||
// 模型须为已启用且勾选「聊天模型」的对话模型,再按其响应类型分流调用:
|
||
// 流式走 ModelCallStream(逐 chunk 推增量),同步/异步走 ModelCallResult(一次返回)
|
||
modelInfo, err := gateway.GetModelInfoById(ctx, &gateway.GetModelInfoByIdReq{ModelId: a.ModelId})
|
||
if err != nil {
|
||
return "", fmt.Errorf("获取模型配置失败: %w", err)
|
||
}
|
||
if modelInfo.ModelManage.Enabled == nil || !*modelInfo.ModelManage.Enabled {
|
||
return "", fmt.Errorf("模型 [%s] 未启用", modelInfo.ModelManage.ModelName)
|
||
}
|
||
if modelInfo.ModelManage.ChatModel == nil || !*modelInfo.ModelManage.ChatModel {
|
||
return "", fmt.Errorf("模型 [%s] 不是对话模型,请在模型配置中勾选「聊天模型」", modelInfo.ModelManage.ModelName)
|
||
}
|
||
isStream := *modelInfo.ModelManage.ResponseType == *model.ResponseTypeStream.Code()
|
||
|
||
messages := map[string]any{
|
||
"user_prompt": userInput,
|
||
"system_prompt": a.SystemPrompt,
|
||
"tools": a.rawTools(a.ToolList),
|
||
}
|
||
for step := 0; step < a.MaxStep; step++ {
|
||
a.emit(ReActEvent{Type: ReActEventModelCall, Step: step + 1, MaxStep: a.MaxStep})
|
||
var result *gateway.ModelCallRes
|
||
if isStream {
|
||
result, err = gateway.ModelCallStream(ctx, a.ModelId, a.SessionId, nil, messages, func(chunk map[string]any) error {
|
||
// 文本增量从 content 子对象取,字段名由网关端结构体统一管理
|
||
if content, ok := chunk["content"].(map[string]any); ok {
|
||
for _, v := range content {
|
||
if s := gconv.String(v); s != "" {
|
||
a.emit(ReActEvent{Type: ReActEventAnswerChunk, Delta: s})
|
||
}
|
||
}
|
||
}
|
||
// 思考内容增量从 reasoningContent 字段取(model-gateway 按 ResponseBusinessFieldMapping 配置返回)
|
||
if s := gconv.String(chunk["reasoningContent"]); s != "" {
|
||
a.emit(ReActEvent{Type: ReActEventReasoningChunk, Delta: s})
|
||
}
|
||
return nil
|
||
})
|
||
} else {
|
||
// 同步/异步对话模型:结果一次返回,无增量事件(异步 msgTopic 为空时由 gateway.ModelCallResult 自动生成)
|
||
result, err = gateway.ModelCallResult(ctx, a.ModelId, modelInfo.ModelManage.ResponseType, a.SessionId, nil, messages)
|
||
}
|
||
// 出错时 result 仍可能携带已累计的 token/cost(流中断返回 res),先累加再判错
|
||
if result != nil {
|
||
a.TotalTokens += result.TotalTokens
|
||
a.TotalCost += result.Cost
|
||
}
|
||
if err != nil {
|
||
// 前端终止/上下文取消:静默停止,不推错误事件
|
||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||
return "", err
|
||
}
|
||
a.emit(ReActEvent{Type: ReActEventError, Message: "模型调用失败", Error: err.Error()})
|
||
return "", fmt.Errorf("step %d: model call failed: %w", step, err)
|
||
}
|
||
var content string
|
||
var toolCalls []gateway.ModelTool
|
||
if isStream {
|
||
for _, v := range gconv.Map(result.Content) {
|
||
content += v.(string)
|
||
}
|
||
toolCalls = result.Tools
|
||
} else {
|
||
// 同步响应 Content 按 responseBodyMapping 抽取(content/toolCalls/finishReason),工具在 toolCalls 里
|
||
content, toolCalls = syncChatResult(result.Content)
|
||
}
|
||
// 无工具调用 → 最终回答
|
||
if len(toolCalls) == 0 {
|
||
a.emit(ReActEvent{Type: ReActEventAnswer, Answer: content})
|
||
return content, nil
|
||
}
|
||
messages["assistant_prompt"] = content
|
||
for _, tool := range toolCalls {
|
||
fn := gconv.Map(tool.Function)
|
||
name := gconv.String(fn["name"])
|
||
arguments := gconv.String(fn["arguments"])
|
||
desc := a.toolDescription(name)
|
||
a.emit(ReActEvent{Type: ReActEventToolCall, Description: desc})
|
||
// 执行每个工具调用
|
||
toolMsg := a.executeToolCall(ctx, name, arguments)
|
||
a.emit(ReActEvent{Type: ReActEventToolResult, Description: desc})
|
||
messages["tool_prompt"] = toolMsg
|
||
messages["tool_id"] = tool.Id
|
||
}
|
||
}
|
||
err = fmt.Errorf("max steps reached %d, generation incomplete", a.MaxStep)
|
||
a.emit(ReActEvent{Type: ReActEventError, Message: "最大步数达到,生成失败", Error: err.Error()})
|
||
return "", err
|
||
}
|
||
|
||
// syncChatResult 解析同步对话响应 Content(model-gateway 按 responseBodyMapping 抽取,固定
|
||
// content/toolCalls/finishReason 三个 key):返回最终文本与工具调用列表(同步响应工具在 toolCalls 里,res.Tools 为空)。
|
||
func syncChatResult(content map[string]any) (string, []gateway.ModelTool) {
|
||
text := gconv.String(content["content"])
|
||
raw := content["toolCalls"]
|
||
if raw == nil {
|
||
raw = content["tools"]
|
||
}
|
||
var toolCalls []gateway.ModelTool
|
||
if arr, ok := raw.([]any); ok {
|
||
for _, t := range arr {
|
||
m := gconv.Map(t)
|
||
if m == nil {
|
||
continue
|
||
}
|
||
var tool gateway.ModelTool
|
||
tool.Id = gconv.String(m["id"])
|
||
tool.Type = gconv.String(m["type"])
|
||
if fn := gconv.Map(m["function"]); fn != nil {
|
||
tool.Function.Name = gconv.String(fn["name"])
|
||
tool.Function.Arguments = gconv.String(fn["arguments"])
|
||
}
|
||
toolCalls = append(toolCalls, tool)
|
||
}
|
||
}
|
||
return text, toolCalls
|
||
}
|
||
|
||
// emit 触发过程事件回调;未设置 OnEvent 时忽略(HTTP 同步调用路径)
|
||
func (a *ReActAgent) emit(ev ReActEvent) {
|
||
if a.OnEvent != nil {
|
||
a.OnEvent(ev)
|
||
}
|
||
}
|
||
|
||
// executeToolCall 执行单个工具调用,返回回灌给模型的工具结果消息。
|
||
// 未知工具由 tools.Default.Call 返回结构化 not_found;工具业务失败进 ToolResult.Code。
|
||
func (a *ReActAgent) executeToolCall(ctx context.Context, name, arguments string) string {
|
||
if arguments == "" {
|
||
return "工具参数为空"
|
||
}
|
||
|
||
var args map[string]any
|
||
if err := json.Unmarshal([]byte(arguments), &args); err != nil {
|
||
return fmt.Sprintf("参数解析失败: %v", err)
|
||
}
|
||
|
||
res, callErr := tools.Default.Call(ctx, name, args)
|
||
|
||
switch {
|
||
case callErr != nil:
|
||
msg := fmt.Sprintf("工具执行失败: %v", callErr)
|
||
return msg
|
||
case res.Code != 0:
|
||
return res.Message
|
||
default:
|
||
dataJSON, err := json.Marshal(res.Data)
|
||
if err != nil {
|
||
return fmt.Sprintf("工具结果序列化失败: %v", err)
|
||
}
|
||
return string(dataJSON)
|
||
}
|
||
}
|
||
|
||
// toolDescription 按工具名从 ToolList 中查找用途说明(推给前端展示用,不暴露工具名/参数)
|
||
func (a *ReActAgent) toolDescription(name string) string {
|
||
for _, t := range a.ToolList {
|
||
if t.Name == name {
|
||
return t.Description
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// RawTools 将工具定义转为 OpenAI 原始数组,parameters 保持 JSON Schema 原样。
|
||
// businessParams 里传 tools 必须用它转换——直接传 []*tools.Tool 会被 json.Marshal 序列化 Func 字段而失败。
|
||
func (a *ReActAgent) rawTools(toolList []*tools.Tool) []any {
|
||
raw := make([]any, 0, len(toolList))
|
||
for _, t := range toolList {
|
||
params := t.Parameters
|
||
if params == nil {
|
||
params = map[string]any{}
|
||
}
|
||
raw = append(raw, map[string]any{
|
||
"type": "function",
|
||
"function": map[string]any{
|
||
"name": t.Name,
|
||
"description": t.Description,
|
||
"parameters": params,
|
||
},
|
||
})
|
||
}
|
||
return raw
|
||
}
|