相信很多人在 C 语言课程设计的时候都做过类似记事本之类的小工具,but 时间长了,很难避免失去创新力,那今天就带着大家用 AI 去打败魔法,做一个专业独一份的 TODO 小工具吧😋😋😋
如果想快速了解 Agent 功能的同学可以跳过基础功能开发这一部分,直接浏览正片开始部分!!!
写个小 Demo
需求分析
首先,我们来看一下一个常规的 TODO 小工具需要哪些功能
- 增添 TODO
- 删除 TODO
- 更新 TODO
- 展示 TODO
业务抽象
定义业务方法
所以我们可以看到我们现在需要完成最基础的增删改查功能,对应代码结构如下
// repositry/todo_list.go
// TodoList TODO 需求抽象
type TodoList interface {
AddTodo(ctx context.Context, params *model.TodoListParams) error // 新增 TODO
DeleteTodo(ctx context.Context, id string) error // 删除 TODO
UpdateTodo(ctx context.Context, params *model.TodoListParams) error // 更新 TODO
ListTodo(ctx context.Context) ([]*model.TodoListParams, error) // 展示 TODO
GetTodoByContent(ctx context.Context, content string) (string, error) // 通过内容获取 TODO
}
定义存储结构
而我们的 TODO 又需要哪一部分信息呢?
- 唯一标识
- 名称
- 描述
- 备注
- 开始时间
- 结束时间
- 是否完成
// model/todo.go
// TodoListParams TODO 信息
type TodoListParams struct {
Id string `json:"id"`
Content string `json:"content"`
Description string `json:"description"`
Remark string `json:"remark"`
StartedAt *int64 `json:"started_at,omitempty"`
Deadline *int64 `json:"deadline,omitempty"`
Done bool `json:"done"`
}
那现在我们还需要将多个 TODO 信息给串联起来,我们在 go 中有非常多的结构可以选择,e.g.切片、链表、map...
对照一下我们的业务需求,也没有特别复杂的流程,同时为了实现起来更简单,因此我们在这里选择使用切片类型
// repository/todo_list.go
// todoList TODO 列表
type todoList struct {
Todos []*model.TodoListParams `json:"todos,omitempty"`
}
同时我们还需要全剧唯一 ID 生成器,在这里我们选择使用 Google 的 UUID,因此其结构变为了
// repository/todo_list.go
// todoList TODO 列表
type todoList struct {
uid *uuid.UUID
Todos []*model.TodoListParams `json:"todos,omitempty"`
}
同时我们为该结构添加一个构造方法 NewToDoList
// repository/todo_list.go
func NewTodoList() TodoList {
uidGenerator, err := uuid.NewUUID()
if err != nil {
return nil
}
return &todoList{
uid: &uidGenerator,
Todos: make([]*model.TodoListParams, 0),
}
}
定义传输对象与实体类转化方法
// api/todo.go
// TodoAddParams TODO 添加参数
type TodoAddParams struct {
Content string `json:"content"`
Description string `json:"description"`
Remark string `json:"remark"`
StartedAt *int64 `json:"started_at,omitempty"`
Deadline *int64 `json:"deadline,omitempty"`
}
type TodoUpdateParams struct {
Id string `json:"id" jsonschema:"description=id of the todo"`
Content string `json:"content,omitempty" jsonschema:"description=content of the todo"`
Description string `json:"description,omitempty" jsonschema:"description=description of the todo"`
Remark string `json:"remark,omitempty" jsonschema:"description=remark of the todo"`
StartedAt *int64 `json:"started_at,omitempty" jsonschema:"description=start time in unix timestamp"`
Deadline *int64 `json:"deadline,omitempty" jsonschema:"description=deadline of the todo in unix timestamp"`
Done bool `json:"done,omitempty" jsonschema:"description=done status"`
}
func (t *TodoAddParams) ConvertToModel() *model.TodoListParams {
return &model.TodoListParams{
Content: t.Content,
Description: t.Description,
Remark: t.Remark,
StartedAt: t.StartedAt,
Deadline: t.Deadline,
Done: Status_TODO,
}
}
func (t *TodoUpdateParams) ConvertToModel() *model.TodoListParams {
return &model.TodoListParams{
Id: t.Id,
Content: t.Content,
Description: t.Description,
Remark: t.Remark,
StartedAt: t.StartedAt,
Deadline: t.Deadline,
Done: t.Done,
}
}
方法实现
添加 TODO
// repository/todo_list.go
// AddTodo 新增 TODO
func (t *todoList) AddTodo(ctx context.Context, params *model.TodoListParams) error {
if len(t.Todos) != 0 {
for _, todo := range t.Todos {
if todo.Content == params.Content {
return errors.New("todo already exists")
}
}
}
params.Id = t.uid.String()
t.Todos = append(t.Todos, params)
return nil
}
删除 TODO
// repository/todo_list.go
// DeleteTodo 删除 TODO
func (t *todoList) DeleteTodo(ctx context.Context, id string) error {
if len(t.Todos) == 0 {
return errors.New("the todo list is null")
}
for i, todo := range t.Todos {
if todo.Id == id {
t.Todos = append(t.Todos[:i], t.Todos[i+1:]...)
break
}
}
return nil
}
更新 TODO
// repository/todo_list.go
// UpdateTodo 更新 TODO
func (t *todoList) UpdateTodo(ctx context.Context, params *model.TodoListParams) error {
if len(t.Todos) == 0 {
return errors.New("the todo list is null")
}
for _, todo := range t.Todos {
if todo.Id == params.Id {
todo.Content = params.Content
todo.StartedAt = params.StartedAt
todo.Deadline = params.Deadline
todo.Done = params.Done
return nil
}
}
return errors.New("todo not found")
}
展示 TODO
// repository/todo_list.go
// ListTodo 展示 TODO
func (t *todoList) ListTodo(ctx context.Context) ([]*model.TodoListParams, error) {
// 按照开始时间排序
sort.Slice(t.Todos, func(i, j int) bool {
return *t.Todos[i].StartedAt < *t.Todos[j].StartedAt
})
return t.Todos, nil
}
根据内容获取 ID
// repository/todo_list.go
// GetTodoByContent 通过内容获取 TODO
func (t *todoList) GetTodoByContent(ctx context.Context, content string) (string, error) {
if len(t.Todos) == 0 {
return "", errors.New("the todo list is null")
}
for _, todo := range t.Todos {
if todo.Content == content {
return todo.Id, nil
}
}
return "", errors.New("todo not found")
}
实现业务逻辑
fmt.Println("Hello, I'm Eino, your assistant. you can ask me the following questions: ")
for {
var userInput string
fmt.Println("1. add a todo item")
fmt.Println("2. remove a todo item")
fmt.Println("3. update a todo item")
fmt.Println("4. list the todo items")
fmt.Println("5. quit")
fmt.Print("Please input the number of the question: ")
_, err := fmt.Scanln(&userInput)
if err != nil {
log.Fatalf("fmt.Scanln failed, err = %v", err)
}
userKey, err := strconv.Atoi(userInput)
if err != nil {
log.Fatalf("strconv.Atoi failed, err = %v", err)
}
switch userKey {
case 1:
a.AddTodo(ctx)
case 2:
a.DeleteTodo(ctx)
case 3:
a.UpdateTodo(ctx)
case 4:
a.ListTodo(ctx)
case 5:
return
default:
fmt.Println("invalid input")
}
}
由于项目内容比较多,而且大多为基础的操作,在这里就不再展开讲了,源代码放在了 GitHub 仓库中了,有需要的大家可以自行查看
正片开始
什么是 Agent
Agent(智能代理)是一个能够感知环境并采取行动以实现特定目标的系统。在 AI 应用中,Agent 通过结合大语言模型的理解能力和预定义工具的执行能力,可以自主地完成复杂的任务。是未来 AI 应用到生活生产中主要的形态。
—— CloudWeGo Eino 团队
所谓的 Agent 就是赋予 AI 行动能力,让 AI 通过自身思考去利用工具完成当前任务,相比于传统的 LLM 工具,Agent 赋予了 AI 更强的自主性,从一个问答助手变成了用户的左膀右臂。
在最早期的 AI 形态中,LLM 只能通过对话的方式帮助我们解决问题,而现在,由于 Agent 的发展,越来越多的 AI 助手有了新的能力 大家比较常见的 Agent 流程一般会有以下几种:
- 代码补全: 像 cursor、豆包 MarsCode 啦,可以去帮你生成代码、并完成代码补全
- 联网搜索:现在像 ChatGPT-4o、豆包等都支持联网搜索能力啦,这也属于 Agent 的一部分
Agent 由哪几部分组成
Agent 的构成目前主要分为 Chat Model 与 Tool
- Chat Model: 通过强大的语言理解能力处理用户的自然语言输入,并理解用户请求、分析任务需求,并决定是否调用某种工具完成任务
- Tool:是 Agent 的执行器,也就是 Chat Model 的双手。每个 Tool 都应该对其能力作详细描述,从而使 Chat Model 更好的去完成调用,从而实现各种附加功能
Agent 流程分为哪几部分
sequenceDiagram
participant User
participant Chat Model
participant Tool
User ->> Chat Model : Query
Chat Model ->> Tool : Call
Tool -->> Chat Model : Response
Chat Model -->> User : Genterator Answer
通常情况下,Agent 流程一般会分为以下几个部分:
- 由 Chat Model 处理用户请求、识别用户意图、分析用户需求
- 调用 Tool 获取信息 or 完成操作
- 由 Chat Model 将操作结果或者获取到的信息作总结,通过自然语言的形式返回给用户
往往人们为了模型充分了解用户意图,并保证对任务的完成度,一般还会由 Chat Model 来验证该操作是否合理、是否达到用户的预期效果
如何在 Eino 体系下实现一个 Agent 应用
我们将在文章第一部分写的小 Demo 的基础上扩充 Agent 能力,在这里我们会使用由 Eino 提供的三种编写自定义工具的方式,各实现一种对应功能,并通过这几个 Tool 和 Eino 官方提供的搜索工具,为我们的 Todo 应用添加一个 AI 助理。
1. 编写 Tools,赋予 LLM 崭新能力
在有关 Tool 部分,Eino 给出了三种实现方式,分别是:
NewTool构建方式
// 获取添加 todo 工具
// 使用 utils.NewTool 创建工具
func getAddTodoTool() tool.InvokableTool {
info := &schema.ToolInfo{
Name: "add_todo",
Desc: "Add a todo item",
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"content": {
Desc: "The content of the todo item",
Type: schema.String,
Required: true,
},
"description": {
Desc: "The description of the todo item",
Type: schema.String,
Required: true,
},
"remark": {
Desc: "The remark of the todo item, to save some additional information, eg: search information, repository information",
Type: schema.String,
},
"started_at": {
Desc: "The started time of the todo item, in unix timestamp",
Type: schema.Integer,
},
"deadline": {
Desc: "The deadline of the todo item, in unix timestamp",
Type: schema.Integer,
},
}),
}
return utils.NewTool(info, AddTodoFunc)
}
func AddTodoFunc(ctx context.Context, params *api.TodoAddParams) (string, error) {
log.Printf("invoke utils add_todo: %+v", params)
// 具体的调用逻辑
if err := service.GetTodoService().AddTodo(ctx, params); err != nil {
return `"msg": "add todo failed, please try again"`, err
}
return `{"msg": "add todo success"}`, nil
}
通过
NewTool方法构建的 ToolNode 具有直观、简单、方便等特点,但是在实际业务场景下,会导致代码高度耦合、配置散落在各处,在后续随工程逐渐复杂之后,对后续的维护造成了一定的困难
InferTool构建方式
type TodoUpdateParams struct {
Id string `json:"id" jsonschema:"description=id of the todo"`
Content string `json:"content,omitempty" jsonschema:"description=content of the todo"`
Description string `json:"description,omitempty" jsonschema:"description=description of the todo"`
Remark string `json:"remark,omitempty" jsonschema:"description=remark of the todo"`
StartedAt *int64 `json:"started_at,omitempty" jsonschema:"description=start time in unix timestamp"`
Deadline *int64 `json:"deadline,omitempty" jsonschema:"description=deadline of the todo in unix timestamp"`
Done bool `json:"done,omitempty" jsonschema:"description=done status"`
}
updateTool, err := utils.InferTool("update_todo", "Update a todo item, eg: content,deadline...", UpdateTodoFunc)
if err != nil {
panic(err)
}
func UpdateTodoFunc(ctx context.Context, params *api.TodoUpdateParams) (string, error) {
log.Printf("invoke utils update_todo: %+v", params)
// 具体的调用逻辑
if err := service.GetTodoService().UpdateTodo(ctx, params); err != nil {
return `"msg": "add todo failed, please try again"`, err
}
return `{"msg": "update todo success"}`, nil
}
通过
InferTool构建 ToolNode 的方式来构建,我们可以通过 tag 的方式实现参数结构体与描述信息的绑定,在一定程度上降低了我们的维护成本
- 自定义实现
而自己实现的话,我们只需实现BaseTool接口或者InvokableTool接口,即只需要实现Info(ctx context.Context) (*schema.ToolInfo, error)(必须) 和InvokableRun(ctx context.Context, argumentsInJSON string, opts ...Option)(非必须)
// ListTodoTool
// 获取列出 todo 工具
type ListTodoTool struct {
service.TodoService
}
func (lt *ListTodoTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: "list_todo",
Desc: "List all todo items",
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"finished": {
Desc: "filter todo items if finished",
Type: schema.Boolean,
Required: false,
},
}),
}, nil
}
func (lt *ListTodoTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
log.Printf("invoke utils list_todo: %s", argumentsInJSON)
// 具体的调用逻辑
listTodo, err := lt.TodoService.ListTodo(ctx)
if err != nil {
return "", err
}
return listTodo, nil
}
其实这个方法还是自己比较喜欢的,因为构建的自由度在这里摆着,也只需要实现两个方法,同时难度也并不大,so 就很爽~
2. 将 Tool 绑定到 Chat Model 上,赋予 Chat Model 新能力
// 整合工具类
tools := []tool.BaseTool{
getAddTodoTool(), // 使用 NewTool 方式
updateTool, // 使用 InferTool 方式
listTool, // 使用结构体实现方式, 此处未实现底层逻辑
searchTool,
}
// 获取工具信息, 用于绑定到 ChatModel
toolInfos := make([]*schema.ToolInfo, 0, len(tools))
for _, todoTool := range tools {
info, err := todoTool.Info(ctx)
if err != nil {
panic(err)
}
toolInfos = append(toolInfos, info)
}
// 将 tools 绑定到 ChatModel
err := chatModel.BindTools(toolInfos)
if err != nil {
panic(err)
}
我们可以看到,为 Chat Model 绑定对应的 Tool 工具,我们只需要将 Tool Info 交给 Chat Model,让我们的 LLM 去理解我们为其构建的 Tool 的能力
3. 使用 Eino Chain 对流程进行编排
- 构建
Tool Node节点
// 创建 tools 节点
todoToolsNode, err := compose.NewToolNode(context.Background(), &compose.ToolsNodeConfig{
Tools: tools,
})
if err != nil {
panic(err)
}
- 构建完整的工具链
- 首先我们通过 go 范型机制实现类型对齐
chain := compose.NewChain[[]*schema.Message, []*schema.Message]()在这一部分中,我们定义了整个工具链的出入参,即第一个节点的输入与最后一个节点的输出 - 实现对工具链的完整编排
在这一部分中,我们需要注意上下游类型对齐的基本准则,即当前节点的出参类型与下一节点的入参相同chain. AppendChatModel(chatModel, compose.WithNodeName("chat_model")). AppendToolsNode(todoToolsNode, compose.WithNodeName("tools")) - 编译工具链
agent, err := chain.Compile(ctx),当我们完成对工具链的编译后,我们便可以通过像上节课中的Invoke或者Stream方式进行愉快的调用啦~
- 首先我们通过 go 范型机制实现类型对齐
The End
到此我们就完成整个 Agent 流程的构建啦,你的指导老师再也不敢说你没有创造力喽,由于完整代码过于长,就放在 GitHub 仓库中啦,后续会将这一系列的代码统一提交到该仓库,还请大家给个 Star~,路过留赞🎉~
我是KingYen.,一个不入流大学的大三计科生,欢迎你的喜欢~