This commit is contained in:
fuzhongyun 2025-11-18 18:37:58 +08:00
parent bb5d32ca70
commit a838b87d7e
20 changed files with 466 additions and 173 deletions

Binary file not shown.

Binary file not shown.

View File

@ -12,6 +12,7 @@ import (
"eino-project/internal/data" "eino-project/internal/data"
"eino-project/internal/data/repoimpl" "eino-project/internal/data/repoimpl"
"eino-project/internal/domain/context" "eino-project/internal/domain/context"
"eino-project/internal/domain/llm"
"eino-project/internal/domain/monitor" "eino-project/internal/domain/monitor"
"eino-project/internal/domain/session" "eino-project/internal/domain/session"
"eino-project/internal/domain/vector" "eino-project/internal/domain/vector"
@ -44,8 +45,10 @@ func wireApp(confServer *conf.Server, confData *conf.Data, bootstrap *conf.Boots
customerRepo := repoimpl.NewCustomerRepo(dataData, logger) customerRepo := repoimpl.NewCustomerRepo(dataData, logger)
customerUseCase := biz.NewCustomerUseCase(customerRepo, logger, knowledgeSearcher, documentProcessor, contextManager, monitorMonitor) customerUseCase := biz.NewCustomerUseCase(customerRepo, logger, knowledgeSearcher, documentProcessor, contextManager, monitorMonitor)
sessionManager := session.NewMemorySessionManager(logger) sessionManager := session.NewMemorySessionManager(logger)
customerService := service.NewCustomerService(customerUseCase, sessionManager, monitorMonitor, logger) llmLLM := llm.NewLLM(bootstrap)
httpServer := server.NewHTTPServer(confServer, customerService, logger) customerService := service.NewCustomerService(customerUseCase, sessionManager, monitorMonitor, logger, llmLLM)
agentService := service.NewAgentService(logger, llmLLM)
httpServer := server.NewHTTPServer(confServer, customerService, agentService, logger)
app := newApp(logger, httpServer) app := newApp(logger, httpServer)
return app, func() { return app, func() {
cleanup() cleanup()

View File

@ -38,23 +38,23 @@ services:
- eino-network - eino-network
# Coze-Loop 监控服务 # Coze-Loop 监控服务
coze-loop: # coze-loop:
image: cozedev/coze-loop:latest # image: cozedev/coze-loop:latest
container_name: eino-coze-loop # container_name: eino-coze-loop
ports: # ports:
- "8080:8080" # - "8080:8080"
environment: # environment:
- COZE_LOOP_PORT=8080 # - COZE_LOOP_PORT=8080
- COZE_LOOP_HOST=0.0.0.0 # - COZE_LOOP_HOST=0.0.0.0
volumes: # volumes:
- coze_data:/app/data # - coze_data:/app/data
healthcheck: # healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"] # test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 20s # interval: 20s
timeout: 5s # timeout: 5s
retries: 3 # retries: 3
networks: # networks:
- eino-network # - eino-network
# 智能客服后端服务 # 智能客服后端服务
eino-service: eino-service:

View File

@ -10,6 +10,7 @@ require (
github.com/coze-dev/cozeloop-go v0.1.15 github.com/coze-dev/cozeloop-go v0.1.15
github.com/go-kratos/kratos/v2 v2.8.0 github.com/go-kratos/kratos/v2 v2.8.0
github.com/google/wire v0.6.0 github.com/google/wire v0.6.0
github.com/gorilla/websocket v1.5.3
github.com/mattn/go-sqlite3 v1.14.22 github.com/mattn/go-sqlite3 v1.14.22
github.com/redis/go-redis/v9 v9.14.1 github.com/redis/go-redis/v9 v9.14.1
go.uber.org/automaxprocs v1.5.1 go.uber.org/automaxprocs v1.5.1
@ -46,7 +47,6 @@ require (
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/goph/emperror v0.17.2 // indirect github.com/goph/emperror v0.17.2 // indirect
github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/mux v1.8.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/invopop/yaml v0.1.0 // indirect github.com/invopop/yaml v0.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect

View File

@ -1,5 +1,3 @@
cel.dev/expr v0.16.1 h1:NR0+oFYzR1CqLFhTAqg3ql59G9VfN8fKq1TCHJ6gq1g=
cel.dev/expr v0.16.1/go.mod h1:AsGA5zb3WruAEQeQng1RZdGEXmBj0jvMWh6l5SnNuC8=
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
@ -27,8 +25,6 @@ github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMrBo8f1j86j5WHzznCCQxV/b8g=
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
@ -38,8 +34,6 @@ github.com/cloudwego/eino v0.5.10 h1:/U5el2Ky5nKY1mE3nKHh8fnPKka5xlC19PW0s/jvbxw
github.com/cloudwego/eino v0.5.10/go.mod h1:N6E+toMzWw/3ql0IVM5n5lbYFCeblCYx7ebH16kt1JQ= github.com/cloudwego/eino v0.5.10/go.mod h1:N6E+toMzWw/3ql0IVM5n5lbYFCeblCYx7ebH16kt1JQ=
github.com/cloudwego/eino-ext/components/model/ollama v0.1.5 h1:afBsBFwk8cRT3RT5LOW8jTdV4uZipCQpNQuBwM1zWL4= github.com/cloudwego/eino-ext/components/model/ollama v0.1.5 h1:afBsBFwk8cRT3RT5LOW8jTdV4uZipCQpNQuBwM1zWL4=
github.com/cloudwego/eino-ext/components/model/ollama v0.1.5/go.mod h1:agEXRRhFGNcTjaryXKNYxI58niFnVws5ipOz5QkScIc= github.com/cloudwego/eino-ext/components/model/ollama v0.1.5/go.mod h1:agEXRRhFGNcTjaryXKNYxI58niFnVws5ipOz5QkScIc=
github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 h1:QVw89YDxXxEe+l8gU8ETbOasdwEV+avkR75ZzsVV9WI=
github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/coze-dev/cozeloop-go v0.1.15 h1:oUQ7U1h4AyPd1IUR+Ob7TDtby/cjhZvBz+vEH7obncI= github.com/coze-dev/cozeloop-go v0.1.15 h1:oUQ7U1h4AyPd1IUR+Ob7TDtby/cjhZvBz+vEH7obncI=
github.com/coze-dev/cozeloop-go v0.1.15/go.mod h1:lM7cmUEZlnAlQYdwfk4Li0SC3RdZ++QMHX75nvKceSc= github.com/coze-dev/cozeloop-go v0.1.15/go.mod h1:lM7cmUEZlnAlQYdwfk4Li0SC3RdZ++QMHX75nvKceSc=
github.com/coze-dev/cozeloop-go/spec v0.1.4-0.20250829072213-3812ddbfb735 h1:qxAwjHy0SLQazDO3oGJ8D24vOeM2Oz2+n27bNPegBls= github.com/coze-dev/cozeloop-go/spec v0.1.4-0.20250829072213-3812ddbfb735 h1:qxAwjHy0SLQazDO3oGJ8D24vOeM2Oz2+n27bNPegBls=
@ -55,10 +49,6 @@ github.com/eino-contrib/jsonschema v1.0.2 h1:HaxruBMUdnXa7Lg/lX8g0Hk71ZIfdTZXmBQ
github.com/eino-contrib/jsonschema v1.0.2/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4= github.com/eino-contrib/jsonschema v1.0.2/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4=
github.com/eino-contrib/ollama v0.1.0 h1:z1NaMdKW6X1ftP8g5xGGR5zDRPUtuTKFq35vBQgxsN4= github.com/eino-contrib/ollama v0.1.0 h1:z1NaMdKW6X1ftP8g5xGGR5zDRPUtuTKFq35vBQgxsN4=
github.com/eino-contrib/ollama v0.1.0/go.mod h1:mYsQ7b3DeqY8bHPuD3MZJYTqkgyL6LoemxoP/B7ZNhA= github.com/eino-contrib/ollama v0.1.0/go.mod h1:mYsQ7b3DeqY8bHPuD3MZJYTqkgyL6LoemxoP/B7ZNhA=
github.com/envoyproxy/go-control-plane v0.13.0 h1:HzkeUz1Knt+3bK+8LG1bxOO/jzWZmdxpwC51i202les=
github.com/envoyproxy/go-control-plane v0.13.0/go.mod h1:GRaKG3dwvFoTg4nj7aXdZnvMg4d7nvT/wl9WgVXn3Q8=
github.com/envoyproxy/protoc-gen-validate v1.1.0 h1:tntQDh69XqOCOZsDz0lVJQez/2L6Uu2PdjCQwWCJ3bM=
github.com/envoyproxy/protoc-gen-validate v1.1.0/go.mod h1:sXRDRVmzEbkM7CVcM06s9shE/m23dg3wzjl0UWqJ2q4=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
@ -174,8 +164,6 @@ github.com/perimeterx/marshmallow v1.1.4/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0V
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.2-0.20201214064552-5dd12d0cfe7f h1:lJqhwddJVYAkyp72a4pwzMClI20xTwL7miDdm2W/KBM= github.com/pkg/errors v0.9.2-0.20201214064552-5dd12d0cfe7f h1:lJqhwddJVYAkyp72a4pwzMClI20xTwL7miDdm2W/KBM=
github.com/pkg/errors v0.9.2-0.20201214064552-5dd12d0cfe7f/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.2-0.20201214064552-5dd12d0cfe7f/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
@ -239,6 +227,8 @@ go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.uber.org/automaxprocs v1.5.1 h1:e1YG66Lrk73dn4qhg8WFSvhF0JuFQF0ERIp4rpuV8Qk= go.uber.org/automaxprocs v1.5.1 h1:e1YG66Lrk73dn4qhg8WFSvhF0JuFQF0ERIp4rpuV8Qk=
go.uber.org/automaxprocs v1.5.1/go.mod h1:BF4eumQw0P9GtnuxxovUd06vwm1o18oMzFtK66vU6XU= go.uber.org/automaxprocs v1.5.1/go.mod h1:BF4eumQw0P9GtnuxxovUd06vwm1o18oMzFtK66vU6XU=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4= golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=

View File

@ -0,0 +1,34 @@
package agent
import (
"context"
"log"
"eino-project/internal/domain/llm"
"github.com/cloudwego/eino/adk"
)
func NewIntentAgent(ctx context.Context, models llm.LLM) adk.Agent {
intentModel, err := models.Intent()
if err != nil {
log.Fatal(err)
}
a, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "意图识别智能体",
Description: "根据用户输入识别意图",
Instruction: `
# 你是一个意图识别智能体根据用户输入识别用户的意图
- 当用户输入商品相关时意图为"商品查询"(product)
- 当用户输入订单相关时意图为"订单诊断"(order)
- 当用户输入其他问题时意图为"其他"(other)
- 输出结构为: {"intent": "product|order|other"}
`,
Model: intentModel,
OutputKey: "intent",
})
if err != nil {
log.Fatal(err)
}
return a
}

View File

@ -1,17 +0,0 @@
package intent
import (
"context"
)
type IntentAgent interface {
Classify(ctx context.Context, message string) (string, error)
}
type passthroughAgent struct{}
func NewPassthrough() IntentAgent { return &passthroughAgent{} }
func (p *passthroughAgent) Classify(ctx context.Context, message string) (string, error) {
return "general_inquiry", nil
}

View File

@ -0,0 +1,48 @@
package agent
import (
"context"
"eino-project/internal/domain/llm"
"eino-project/internal/domain/tools"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/compose"
)
// NewProductChatAgent 使用 ADK ChatModelAgent 构造一个具备工具选择能力的商品查询 Agent
func NewProductChatAgent(ctx context.Context, models llm.LLM) adk.Agent {
chatModel, err := models.Chat()
if err != nil {
return nil
}
toolsCfg := adk.ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{
tools.NewProductByIDTool(),
tools.NewProductSearchByNameTool(),
},
ExecuteSequentially: false,
},
// ReturnDirectly: map[string]bool{
// "get_product_by_id": true,
// "search_product_by_name": true,
// },
}
agent, _ := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "商品查询智能体",
Description: "根据用户输入查询商品信息支持按ID或名称检索",
Model: chatModel,
ToolsConfig: toolsCfg,
Instruction: `
# 你是一个商品查询智能体根据用户输入查询商品信息
- 当用户输入商品ID时优先使用"get_product_by_id"工具查询商品详情
- 当用户输入商品名称时使用"search_product_by_name"工具搜索相关商品
- 当用户需要查询多个结果时合并所有结果返回
- 输出结构为: {"items": [...], "source": "id|name", "count": int} 无需解释文本
`,
})
return agent
}

View File

@ -1,63 +0,0 @@
package product
import (
"encoding/json"
"net/http"
"strings"
tool "eino-project/internal/tools/product"
)
type Handler struct {
tool tool.Tool
}
func NewHandler(t tool.Tool) *Handler { return &Handler{tool: t} }
type queryRequest struct {
Query struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"query"`
Page int `json:"page,omitempty"`
Size int `json:"size,omitempty"`
}
type queryResponse struct {
Items []*tool.Product `json:"items"`
Source string `json:"source"`
Count int `json:"count"`
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req queryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
var items []*tool.Product
src := ""
if id := strings.TrimSpace(req.Query.ID); id != "" {
if p, _ := h.tool.GetByID(r.Context(), id); p != nil {
items = append(items, p)
}
src = "id"
} else if name := strings.TrimSpace(req.Query.Name); name != "" {
res, _ := h.tool.SearchByName(r.Context(), name)
items = res
src = "name"
} else {
http.Error(w, "query.id or query.name required", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(&queryResponse{Items: items, Source: src, Count: len(items)})
}

View File

@ -8,25 +8,25 @@ import (
) )
type LLM interface { type LLM interface {
Chat() (model.BaseChatModel, error) Chat() (model.ToolCallingChatModel, error)
Intent() (model.BaseChatModel, error) Intent() (model.ToolCallingChatModel, error)
} }
type llm struct { type llm struct {
cfg *conf.Bootstrap cfg *conf.Bootstrap
onceChat sync.Once onceChat sync.Once
onceIntent sync.Once onceIntent sync.Once
chat model.BaseChatModel chat model.ToolCallingChatModel
intent model.BaseChatModel intent model.ToolCallingChatModel
cache map[string]model.BaseChatModel cache map[string]model.ToolCallingChatModel
} }
func NewLLM(cfg *conf.Bootstrap) LLM { func NewLLM(cfg *conf.Bootstrap) LLM {
return &llm{cfg: cfg, cache: make(map[string]model.BaseChatModel)} return &llm{cfg: cfg, cache: make(map[string]model.ToolCallingChatModel)}
} }
// 获取Ollama聊天模型实例 // 获取Ollama聊天模型实例
func (r *llm) Chat() (model.BaseChatModel, error) { func (r *llm) Chat() (model.ToolCallingChatModel, error) {
var err error var err error
r.onceChat.Do(func() { r.onceChat.Do(func() {
r.chat, err = newOllamaChatModel(r.cfg) r.chat, err = newOllamaChatModel(r.cfg)
@ -35,7 +35,7 @@ func (r *llm) Chat() (model.BaseChatModel, error) {
} }
// 获取Ollama意图识别模型实例 // 获取Ollama意图识别模型实例
func (r *llm) Intent() (model.BaseChatModel, error) { func (r *llm) Intent() (model.ToolCallingChatModel, error) {
var err error var err error
r.onceIntent.Do(func() { r.onceIntent.Do(func() {
r.intent, err = newOllamaIntentModel(r.cfg) r.intent, err = newOllamaIntentModel(r.cfg)

View File

@ -12,7 +12,7 @@ import (
) )
// newOllamaChatModel Ollama聊天模型实例 // newOllamaChatModel Ollama聊天模型实例
func newOllamaChatModel(c *conf.Bootstrap) (model.BaseChatModel, error) { func newOllamaChatModel(c *conf.Bootstrap) (model.ToolCallingChatModel, error) {
if c == nil || c.Ai == nil || c.Ai.Ollama == nil { if c == nil || c.Ai == nil || c.Ai.Ollama == nil {
return nil, fmt.Errorf("AI configuration is missing") return nil, fmt.Errorf("AI configuration is missing")
} }

View File

@ -12,7 +12,7 @@ import (
) )
// newOllamaIntentModel Ollama意图识别模型实例 // newOllamaIntentModel Ollama意图识别模型实例
func newOllamaIntentModel(c *conf.Bootstrap) (model.BaseChatModel, error) { func newOllamaIntentModel(c *conf.Bootstrap) (model.ToolCallingChatModel, error) {
if c == nil || c.Ai == nil || c.Ai.Ollama == nil { if c == nil || c.Ai == nil || c.Ai.Ollama == nil {
return nil, fmt.Errorf("AI configuration is missing") return nil, fmt.Errorf("AI configuration is missing")
} }

View File

@ -5,7 +5,6 @@ import (
"eino-project/internal/domain/llm" "eino-project/internal/domain/llm"
"eino-project/internal/domain/monitor" "eino-project/internal/domain/monitor"
"eino-project/internal/domain/session" "eino-project/internal/domain/session"
"eino-project/internal/domain/tools"
"eino-project/internal/domain/vector" "eino-project/internal/domain/vector"
"eino-project/internal/domain/workflow" "eino-project/internal/domain/workflow"
@ -18,7 +17,6 @@ var ProviderSet = wire.NewSet(
llm.NewLLM, llm.NewLLM,
monitor.NewMonitorFromBootstrapConfig, monitor.NewMonitorFromBootstrapConfig,
session.NewMemorySessionManager, session.NewMemorySessionManager,
tools.NewMemoryProductTool,
workflow.NewChatWorkflow, workflow.NewChatWorkflow,
vector.NewVectorServiceFromBootstrapConfig, vector.NewVectorServiceFromBootstrapConfig,

View File

@ -3,8 +3,20 @@ package tools
import ( import (
"context" "context"
"strings" "strings"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/components/tool/utils"
) )
var productsMock = []*Product{
{ID: "P001", Name: "手机 A1", Price: 1999, Description: "入门级智能手机"},
{ID: "P002", Name: "手机 Pro X", Price: 5999, Description: "旗舰级拍照手机"},
{ID: "P003", Name: "笔记本 M3", Price: 8999, Description: "轻薄高性能笔记本"},
{ID: "P004", Name: "耳机 Air", Price: 1299, Description: "降噪真无线耳机"},
{ID: "271", Name: "商品 271", Price: 2710, Description: "样例商品用于测试按ID查询"},
}
// 工具返回
type Product struct { type Product struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@ -12,40 +24,49 @@ type Product struct {
Description string `json:"description"` Description string `json:"description"`
} }
type Tool interface { // --- Eino ADK Tool 实现按ID查询 ---
GetByID(ctx context.Context, id string) (*Product, error) type ProductByIDInput struct {
SearchByName(ctx context.Context, name string) ([]*Product, error) ID string `json:"id" jsonschema:"description=商品ID"`
} }
type memoryTool struct { func NewProductByIDTool() tool.InvokableTool {
items []*Product toolImpl, err := utils.InferTool("get_product_by_id", "根据商品ID查询商品详情返回商品对象。", productByID)
if err != nil {
panic(err)
}
return toolImpl
} }
func NewMemoryProductTool() Tool { func productByID(ctx context.Context, in *ProductByIDInput) (*Product, error) {
return &memoryTool{items: []*Product{ for _, it := range productsMock {
{ID: "P001", Name: "手机 A1", Price: 1999, Description: "入门级智能手机"}, if it.ID == in.ID {
{ID: "P002", Name: "手机 Pro X", Price: 5999, Description: "旗舰级拍照手机"},
{ID: "P003", Name: "笔记本 M3", Price: 8999, Description: "轻薄高性能笔记本"},
{ID: "P004", Name: "耳机 Air", Price: 1299, Description: "降噪真无线耳机"},
}}
}
func (m *memoryTool) GetByID(ctx context.Context, id string) (*Product, error) {
for _, it := range m.items {
if it.ID == id {
return it, nil return it, nil
} }
} }
return nil, nil return &Product{}, nil
} }
func (m *memoryTool) SearchByName(ctx context.Context, name string) ([]*Product, error) { // --- Eino ADK Tool 实现:按名称搜索 ---
if name == "" {
return nil, nil type ProductSearchInput struct {
Name string `json:"name" jsonschema:"description=商品名称关键词"`
} }
q := strings.ToLower(name)
func NewProductSearchByNameTool() tool.InvokableTool {
toolImpl, err := utils.InferTool("search_product_by_name", "根据商品名称关键词搜索商品列表,返回数组。", productSearchByName)
if err != nil {
panic(err)
}
return toolImpl
}
func productSearchByName(ctx context.Context, in *ProductSearchInput) ([]*Product, error) {
if in.Name == "" {
return []*Product{}, nil
}
q := strings.ToLower(in.Name)
var out []*Product var out []*Product
for _, it := range m.items { for _, it := range productsMock {
if strings.Contains(strings.ToLower(it.Name), q) { if strings.Contains(strings.ToLower(it.Name), q) {
out = append(out, it) out = append(out, it)
} }

View File

@ -0,0 +1,198 @@
package adkutil
import (
"context"
"encoding/json"
"errors"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
krlog "github.com/go-kratos/kratos/v2/log"
)
type Result struct {
Customized any
Message *schema.Message
}
func QueryJSON[T any](ctx context.Context, agent adk.Agent, query string) (*T, error) {
r, err := Query(ctx, agent, query)
if err != nil {
return nil, err
}
if r.Customized != nil {
if v, ok := r.Customized.(*T); ok {
return v, nil
}
b, _ := json.Marshal(r.Customized)
var out T
if json.Unmarshal(b, &out) == nil {
return &out, nil
}
}
if r.Message != nil && r.Message.Content != "" {
var out T
if json.Unmarshal([]byte(r.Message.Content), &out) == nil {
return &out, nil
}
}
return nil, errors.New("agent output not match target type")
}
func QueryWithLogger(ctx context.Context, agent adk.Agent, query string, logger *krlog.Helper) (Result, error) {
runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: agent})
it := runner.Query(ctx, query)
var out Result
var lastErr error
if logger != nil {
logger.Infof("agent query start: %s", query)
}
for {
ev, ok := it.Next()
if !ok || ev == nil {
break
}
if logger != nil {
logger.Infof("agent event received: err=%v", ev.Err)
}
if ev.Err != nil {
lastErr = ev.Err
}
if ev.Output != nil {
if ev.Output.CustomizedOutput != nil {
out.Customized = ev.Output.CustomizedOutput
if logger != nil {
b, _ := json.Marshal(ev.Output.CustomizedOutput)
logger.Infof("agent customized output=%s", string(b))
}
}
if ev.Output.MessageOutput != nil {
msg, _ := ev.Output.MessageOutput.GetMessage()
if msg != nil {
out.Message = msg
if logger != nil {
logger.Infof("agent message role=%s content=%s", msg.Role, msg.Content)
if len(msg.ToolCalls) > 0 {
for _, tc := range msg.ToolCalls {
if tc.Function.Name != "" {
logger.Infof("agent tool call name=%s args=%s", tc.Function.Name, tc.Function.Arguments)
}
}
}
}
}
}
}
}
if out.Customized != nil || out.Message != nil {
return out, nil
}
if lastErr != nil {
return out, lastErr
}
return out, errors.New("agent no output")
}
func QueryJSONWithLogger[T any](ctx context.Context, agent adk.Agent, query string, logger *krlog.Helper) (*T, error) {
r, err := QueryWithLogger(ctx, agent, query, logger)
if err != nil {
return nil, err
}
if r.Customized != nil {
if v, ok := r.Customized.(*T); ok {
return v, nil
}
b, _ := json.Marshal(r.Customized)
var out T
if json.Unmarshal(b, &out) == nil {
return &out, nil
}
}
if r.Message != nil && r.Message.Content != "" {
var out T
if json.Unmarshal([]byte(r.Message.Content), &out) == nil {
return &out, nil
}
}
return nil, errors.New("agent output not match target type")
}
// Query 对 Agent 发起一次非流式请求,并提取统一结果
// - 优先返回工具的 CustomizedOutput结构化输出
// - 若无,则回退到最终的 MessageOutput文本或可解析JSON
// - 若均无则返回错误agent no output
func Query(ctx context.Context, agent adk.Agent, query string) (Result, error) {
runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: agent})
it := runner.Query(ctx, query)
var out Result
var lastErr error
for {
ev, ok := it.Next()
if !ok || ev == nil {
break
}
if ev.Err != nil {
lastErr = ev.Err
break
}
if ev.Output != nil {
if ev.Output.CustomizedOutput != nil {
out.Customized = ev.Output.CustomizedOutput
}
if ev.Output.MessageOutput != nil {
msg, _ := ev.Output.MessageOutput.GetMessage()
if msg != nil {
out.Message = msg
}
}
}
}
if out.Customized != nil || out.Message != nil {
return out, nil
}
if lastErr != nil {
return out, lastErr
}
return out, errors.New("agent no output")
}
// ToPayload 将 Query 的结果转换为可直接返回的 payload
// - CustomizedOutput 直接返回
// - MessageOutput 尝试当作 JSON 解析,失败则包装为 {"message": text}
func ToPayload(res Result) any {
if res.Customized != nil {
return res.Customized
}
if res.Message != nil {
var obj any
if json.Unmarshal([]byte(res.Message.Content), &obj) == nil {
return obj
}
return map[string]any{"message": res.Message.Content}
}
return nil
}
// Stream 以流式方式消费 Agent 输出,将 MessageOutput 的内容按片段写入 channel
// 用于 SSE/WS 等场景;工具直接返回通常会合并到最终消息
func Stream(ctx context.Context, agent adk.Agent, query string) (<-chan string, error) {
runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: agent, EnableStreaming: true})
it := runner.Query(ctx, query)
ch := make(chan string, 8)
go func() {
defer close(ch)
for {
ev, ok := it.Next()
if !ok || ev == nil {
break
}
if ev.Output != nil && ev.Output.MessageOutput != nil {
msg, _ := ev.Output.MessageOutput.GetMessage()
if msg != nil && msg.Content != "" {
ch <- msg.Content
}
}
}
}()
return ch, nil
}

View File

@ -14,7 +14,7 @@ import (
) )
// NewHTTPServer new an HTTP server. // NewHTTPServer new an HTTP server.
func NewHTTPServer(c *conf.Server, customerService *service.CustomerService, logger log.Logger) *http.Server { func NewHTTPServer(c *conf.Server, customerService *service.CustomerService, agentService *service.AgentService, logger log.Logger) *http.Server {
var opts = []http.ServerOption{ var opts = []http.ServerOption{
http.Middleware( http.Middleware(
recovery.Recovery(), recovery.Recovery(),
@ -37,9 +37,6 @@ func NewHTTPServer(c *conf.Server, customerService *service.CustomerService, log
// 添加SSE流式聊天的自定义路由 // 添加SSE流式聊天的自定义路由
srv.HandleFunc("/api/chat/stream", customerService.HandleStreamChat) srv.HandleFunc("/api/chat/stream", customerService.HandleStreamChat)
// 商品查询 Agent 路由
srv.HandleFunc("/api/agents/product/query", customerService.HandleProductQuery)
// WebSocket 聊天路由(/api/chat/ws // WebSocket 聊天路由(/api/chat/ws
srv.HandleFunc("/api/chat/ws", func(w nethttp.ResponseWriter, r *nethttp.Request) { srv.HandleFunc("/api/chat/ws", func(w nethttp.ResponseWriter, r *nethttp.Request) {
upgrader := websocket.Upgrader{CheckOrigin: func(r *nethttp.Request) bool { return true }} upgrader := websocket.Upgrader{CheckOrigin: func(r *nethttp.Request) bool { return true }}
@ -50,8 +47,11 @@ func NewHTTPServer(c *conf.Server, customerService *service.CustomerService, log
customerService.HandleWebSocketChat(conn) customerService.HandleWebSocketChat(conn)
}) })
route := srv.Route("/api")
// 商品查询 Agent 路由
route.POST("/agents/product/query", agentService.HandleProductQuery)
// 订单诊断工作流(直接调用流水线输出) // 订单诊断工作流(直接调用流水线输出)
srv.HandleFunc("/api/workflow/order/diagnosis", customerService.HandleOrderDiagnosis) // route.POST("/api/workflow/order/diagnosis", customerService.HandleOrderDiagnosis)
return srv return srv
} }

View File

@ -0,0 +1,74 @@
package service
import (
"context"
"eino-project/internal/domain/agent"
"eino-project/internal/domain/llm"
"eino-project/internal/pkg/adkutil"
"encoding/json"
"net/http"
"github.com/cloudwego/eino/adk"
"github.com/go-kratos/kratos/v2/log"
kratoshttp "github.com/go-kratos/kratos/v2/transport/http"
)
type AgentService struct {
productAgent adk.Agent
log *log.Helper
}
func NewAgentService(
logger log.Logger,
models llm.LLM,
) *AgentService {
return &AgentService{
log: log.NewHelper(logger),
// 构建商品查询 ChatModelAgent绑定工具并让模型自动选择调用
productAgent: agent.NewProductChatAgent(context.Background(), models),
}
}
type ProductQueryRes struct {
Items []*ProductItem `json:"items"`
Source string `json:"source"`
Count int `json:"count"`
}
type ProductItem struct {
ID string `json:"id"`
Name string `json:"name"`
Price int `json:"price"`
Description string `json:"description"`
}
type CommonResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data interface{} `json:"data"`
}
// HandleProductQuery 商品查询处理
func (a *AgentService) HandleProductQuery(ctx kratoshttp.Context) error {
type reqType struct {
Message string `json:"message"`
Session string `json:"session_id"`
}
var req reqType
if err := json.NewDecoder(ctx.Request().Body).Decode(&req); err != nil {
return ctx.JSON(http.StatusBadRequest, &CommonResp{
Code: http.StatusBadRequest,
Msg: "Invalid request body",
})
}
if a.productAgent == nil {
return ctx.JSON(http.StatusInternalServerError, &CommonResp{Code: http.StatusInternalServerError, Msg: "product agent not configured"})
}
out, err := adkutil.QueryJSONWithLogger[ProductQueryRes](ctx, a.productAgent, req.Message, a.log)
if err != nil {
return ctx.JSON(http.StatusInternalServerError, &CommonResp{Code: http.StatusInternalServerError, Msg: err.Error()})
}
return ctx.JSON(http.StatusOK, &CommonResp{Code: http.StatusOK, Msg: "success", Data: out})
}

View File

@ -11,10 +11,13 @@ import (
pb "eino-project/api/customer/v1" pb "eino-project/api/customer/v1"
"eino-project/internal/biz" "eino-project/internal/biz"
"eino-project/internal/domain/agent"
"eino-project/internal/domain/llm"
"eino-project/internal/domain/monitor" "eino-project/internal/domain/monitor"
"eino-project/internal/domain/session" "eino-project/internal/domain/session"
wf "eino-project/internal/domain/workflow" wf "eino-project/internal/domain/workflow"
"github.com/cloudwego/eino/adk"
"github.com/go-kratos/kratos/v2/log" "github.com/go-kratos/kratos/v2/log"
) )
@ -27,15 +30,25 @@ type CustomerService struct {
monitor monitor.Monitor monitor monitor.Monitor
log *log.Helper log *log.Helper
chatWorkflow wf.ChatWorkflow chatWorkflow wf.ChatWorkflow
// productAgent 商品查询AgentEino ADK ChatModelAgent实现绑定工具并自动选择调用
productAgent adk.Agent
} }
// NewCustomerService 创建智能客服服务 // NewCustomerService 创建智能客服服务
func NewCustomerService(customerUseCase *biz.CustomerUseCase, sessionManager session.SessionManager, monitor monitor.Monitor, logger log.Logger) *CustomerService { func NewCustomerService(
customerUseCase *biz.CustomerUseCase,
sessionManager session.SessionManager,
monitor monitor.Monitor,
logger log.Logger,
models llm.LLM,
) *CustomerService {
return &CustomerService{ return &CustomerService{
customerUseCase: customerUseCase, customerUseCase: customerUseCase,
sessionManager: sessionManager, sessionManager: sessionManager,
monitor: monitor, monitor: monitor,
log: log.NewHelper(logger), log: log.NewHelper(logger),
// 构建商品查询 ChatModelAgent绑定工具并让模型自动选择调用
productAgent: agent.NewProductChatAgent(context.Background(), models),
} }
} }
@ -603,9 +616,3 @@ func (s *CustomerService) HandleOrderDiagnosis(w http.ResponseWriter, r *http.Re
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"order_id": req.OrderID, "result": resp}) json.NewEncoder(w).Encode(map[string]interface{}{"order_id": req.OrderID, "result": resp})
} }
// HandleProductQuery 商品查询处理
func (s *CustomerService) HandleProductQuery(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{})
}

View File

@ -3,4 +3,4 @@ package service
import "github.com/google/wire" import "github.com/google/wire"
// ProviderSet is service providers. // ProviderSet is service providers.
var ProviderSet = wire.NewSet(NewCustomerService) var ProviderSet = wire.NewSet(NewCustomerService, NewAgentService)