1. 项目概述:为什么“从0到1搭建Agent”不是写个Prompt那么简单?

“从0到1搭建 Agent:Agent 原理分析及个人助手实践总结”——这个标题里藏着一个被严重低估的认知陷阱。很多人点开这类内容,心里想的是:“哦,又是教你怎么用LangChain写个ReAct Prompt,调个天气API,最后在终端里打印一句‘今天北京25度’”。但真正做过三个月以上Agent开发的人会立刻皱眉: 这根本不是Prompt工程,而是一次完整的系统工程重构 。你面对的不是一个“会说话的LLM”,而是一个需要调度、状态管理、错误恢复、资源隔离、执行监控的 自主运行体 。它和传统Web服务有本质区别:Web服务是“请求-响应”模型,一次调用即结束;Agent是“目标-执行-观察-反思-再执行”的闭环系统,它的生命周期由目标驱动,而非HTTP连接。

我去年带团队落地一个内部知识助手时,第一版就是典型的“Prompt+Function Calling”堆砌:用户问“帮我查下Q3销售数据”,系统自动调用BI查询工具,返回JSON,再让LLM格式化成自然语言。上线三天后崩溃了——因为用户接着问“那环比增长多少?”,系统卡死。不是模型不会算,而是它压根没记住上一轮调用了什么、返回了什么、上下文在哪。我们花了两周时间回溯,才发现问题不在模型,而在 整个运行骨架缺失了状态锚点 。后来重写时,我们把 AgentState 结构体放在了所有模块的最底层,连日志打点都强制带上 step_id retry_count 。这才明白,所谓“从0到1”,0不是指没有代码,而是指没有对Agent本质的系统性认知;1不是指跑通Demo,而是指能稳定承载真实工作流的最小可运行单元。

核心关键词“Agent Loop”、“Function Calling”、“个人助手”背后,实际对应着三层硬核能力: 决策层(Loop逻辑与策略选择)、执行层(Tool调用与沙箱控制)、支撑层(状态持久化与事件流管理) 。而“个人助手”这个场景,恰恰是最考验这三层耦合质量的——它不像客服机器人有固定话术边界,也不像数据分析工具只处理结构化输入;它必须应对“帮我把会议纪要发给张三并抄送李四,顺便提醒他明早10点同步评审”这种混合意图、跨系统、带时效性的复合指令。所以本文不讲“怎么调OpenAI API”,而是带你亲手搭起一个能扛住真实用户反复蹂躏的Agent骨架。接下来所有内容,都基于一个原则: 每个设计决策,都必须回答“当用户连续输入10条指令、其中3条失败、2条超时、1条触发循环时,系统如何不崩?”

2. Agent核心架构拆解:为什么Loop是骨架,而不是装饰?

2.1 Agent Loop的本质:一个被严重误解的“循环”

“Agent Loop”这个词听起来像编程里的 for i := 0; i < maxSteps; i++ ,但这是最大的误读。Loop不是语法糖,而是 Agent存在的哲学前提 。我们先看一个反例:假设你写了一个函数 func AskLLM(query string) string ,用户输入“查我的待办事项”,你直接把这句话喂给LLM,它返回“你有3个待办:1. 回复王五邮件;2. 提交周报;3. 预约会议室”。表面看功能完整,但它完全不具备Agent属性——因为 它无法处理后续追问 。当用户紧接着问“把第2项标记为已完成”,系统只能懵逼:它甚至不知道“第2项”指的是什么,更不知道当前状态里有没有“周报”这个实体。

真正的Agent Loop,必须包含四个不可分割的原子动作: Thought → Action → Observation → Reflection 。这不是流程图里的四个框,而是运行时的四个内存切片:

  • Thought :不是LLM的思考过程(那是黑盒),而是程序显式记录的“本步骤决策依据”。比如 "用户要求查询待办,需先调用get_tasks工具获取列表" 。这行文字必须被结构化存储,不能只是日志里的一行字符串。
  • Action :不是“调用某个函数”,而是 带Schema约束的协议声明 。例如 {"tool": "get_tasks", "input": {"status": "pending"}} 。重点在 input 必须是JSON Schema校验过的,而非自由文本拼接。
  • Observation :不是工具返回的原始结果,而是 经程序清洗后的可观测事实 。比如 get_tasks 返回了100条任务,但Observation只存 {"count": 100, "sample": ["回复王五邮件", "提交周报"]} ——既保证信息量,又防止LLM被噪声淹没。
  • Reflection :不是“模型自我批评”,而是 程序层的错误归因与策略修正 。当 get_tasks 超时,Reflection字段应记录 {"error_type": "timeout", "tool": "get_tasks", "suggestion": "降级为本地缓存查询"}

提示:很多初学者把Loop写成 for 循环,结果调试时发现某步失败就整个退出。正确做法是把Loop拆成状态机: IDLE → THOUGHT → ACTION_PENDING → OBSERVATION_RECEIVED → REFLECTION → DONE/ERROR 。每个状态转移都需落盘,这样重启后能从断点续跑。

2.2 Function Calling vs. Text-based ReAct:选型背后的工程代价

网络热词里高频出现“function calling和tool calling的区别”,其实答案很直白: Function Calling是协议,Text-based ReAct是模拟协议的土办法 。就像HTTP/2和手写TCP包的区别。

我们对比两种实现的底层成本:

维度 Text-based ReAct Function Calling
解析稳定性 依赖正则匹配 Action: xxx ,模型输出格式稍变(如多空格、换行)即解析失败 模型直接返回JSON结构体, {"name":"search","arguments":"{...}"} ,无格式干扰
参数安全 Action Input: {"query":"北京天气"} 需手动 json.Unmarshal ,若模型返回 Action Input: query=北京天气 则panic 平台强制校验 arguments 字段为合法JSON,非法输入直接拒收
调试效率 查错要翻日志找“哪一行没匹配上”,耗时30分钟 直接看 tool_calls 数组长度,0表示模型拒绝调用,1表示成功,清晰明了
扩展成本 新增工具需改三处:Prompt模板、正则规则、执行分发器 新增工具只需注册 ToolDefinition ,其余全自动

我实测过:用Text-based ReAct处理1000次工具调用,平均失败率12.7%(主要因模型偶尔输出 Action: search(query="北京天气") );换成Function Calling后,失败率降至0.3%(纯网络超时)。但这不是说ReAct该淘汰——它在 教学场景无可替代 。当你第一次理解“Thought→Action→Observation”时,手写正则比看JSON Schema直观十倍。但一旦进入生产环境,Function Calling是唯一选择。关键在于: 不要把学习路径当成技术选型路径

2.3 个人助手场景的特殊性:为什么它比企业级Agent更难?

“个人助手”看似简单,实则是Agent开发的珠峰。企业级Agent(如客服机器人)有三大护城河: 固定流程、结构化输入、明确退出条件 。而个人助手直面人类思维的混沌性:

  • 意图漂移 :用户首句“查我邮箱”,次句“算了,先看看微信未读”,第三句“等等,把微信里张三发的合同转PDF发我邮箱”。三个指令间无逻辑关联,但Agent必须维持上下文一致性。
  • 权限碎片化 :邮箱需OAuth2,微信需PC客户端注入,PDF转换需本地文件系统访问。每个工具的沙箱策略不同,有的要临时授权码,有的要进程级hook。
  • 失败容忍度极低 :企业用户接受“正在处理,请稍候”,个人用户看到卡顿3秒就会关掉应用。这意味着你的Loop必须在200ms内完成一次迭代,否则就要降级策略。

因此,个人助手的架构必须做三重加固:

  1. 状态轻量化 AgentState 里绝不存原始邮件正文,只存 {"email_id": "abc123", "snippet": "合同已收到..."}
  2. 工具熔断机制 :对微信工具设置 max_retries=1 ,超时立即切到“请打开微信查看”提示,而非死等;
  3. 本地缓存兜底 :当所有远程工具失效时,能从 ~/.agent/cache 读取最近一次成功的会议纪要。

注意:很多教程教你用SQLite存历史,这是大坑。个人助手的 history 不是数据库表,而是 带TTL的LRU缓存 。我见过太多项目因缓存无限增长导致MacBook风扇狂转——正确的做法是 cache.Set("user_history", data, 10*time.Minute) ,用Go的 fastcache 或Python的 cachetools

3. 核心模块实现:从状态定义到流式输出的全链路

3.1 状态结构体设计:为什么字段顺序决定系统寿命?

AgentState 不是数据容器,而是 系统演化的DNA 。它的字段设计直接决定未来半年的维护成本。我们看一个经过生产验证的最小可行结构:

type AgentState struct {
    Goal        string            `json:"goal"`         // 用户原始目标,永不修改
    Steps       []Step            `json:"steps"`        // 所有执行步骤,append-only
    FinalAnswer string            `json:"final_answer,omitempty"` // 最终答案,仅当Loop结束时写入
    StartTime   time.Time         `json:"start_time"`   // Loop启动时间,用于全局超时
    Metadata    map[string]string `json:"metadata"`     // 动态元数据,如"user_id":"u123"
}

type Step struct {
    ID          int               `json:"id"`           // 步骤序号,从1开始
    Thought     string            `json:"thought"`      // 决策依据,非空
    Action      Action            `json:"action"`       // 工具调用声明
    Observation string            `json:"observation,omitempty"` // 工具返回摘要
    Error       string            `json:"error,omitempty"`       // 错误描述,非error类型
    Duration    time.Duration     `json:"duration"`     // 本步骤耗时
    RetryCount  int               `json:"retry_count"`  // 当前重试次数
}

type Action struct {
    Tool  string          `json:"tool"`   // 工具名,必须与注册名完全一致
    Input json.RawMessage `json:"input"`  // JSON参数,保持原始字节避免序列化损耗
}

关键设计点解析:

  • Goal 字段永不修改 :很多项目把Goal存在 state.Goal = "查完邮箱再看微信" ,结果用户中途改口,状态就乱了。正确做法是 Goal 只存初始输入,所有中间状态通过 Steps 推导。
  • Steps 必须是slice而非map []Step 保证时序严格, map[int]Step 在并发写入时可能乱序。我们曾因用map导致“标记已完成”操作跑到“查询待办”之前执行。
  • Error 用string而非error接口 :Go的 error 接口序列化后是 {"Error":"xxx"} ,但前端JS解析时会丢失类型。统一用string,前端可直接 if (step.error) {...} 判断。
  • Input json.RawMessage :避免两次JSON序列化。工具执行时直接 json.Unmarshal(step.Action.Input, &req) ,省去中间struct转换。

实操心得:在 Step 里加 ID 字段看似多余,实则救命。某次线上事故中,用户反馈“第3步结果错了”,运维直接查 SELECT * FROM steps WHERE id=3 AND state_id='xxx' ,5分钟定位到是微信工具返回了旧数据。若没ID,就得全文本搜索 "微信" ,耗时20分钟。

3.2 Loop主循环:如何让1000次迭代不内存泄漏?

主循环是Agent的心脏,但也是最容易写出内存炸弹的地方。错误写法:

// ❌ 危险!每次迭代都创建新state副本
for i := 0; i < maxSteps; i++ {
    newState := *state // 深拷贝整个state
    prompt := buildPrompt(newState)
    resp := llm.Call(prompt)
    newState.Steps = append(newState.Steps, parseStep(resp))
    state = &newState // 指针指向新对象
}

问题: *state 深拷贝会复制所有 Steps ,100步后内存占用翻100倍。正确写法是 状态增量更新

// ✅ 安全!只追加新步骤
for stepID := len(state.Steps) + 1; stepID <= maxSteps; stepID++ {
    start := time.Now()
    
    // 构建Prompt时只传必要字段
    prompt := buildPrompt(AgentContext{
        Goal:  state.Goal,
        Steps: state.Steps[:min(len(state.Steps), 5)], // 只取最近5步
        Meta:  state.Metadata,
    })
    
    resp, err := llm.Call(ctx, prompt)
    if err != nil {
        state.addError(fmt.Sprintf("LLM call failed: %v", err))
        break
    }
    
    step := parseStep(resp, stepID, time.Since(start))
    state.Steps = append(state.Steps, step) // 原地追加
    
    // 检查终止条件
    if step.IsFinal || stepID >= maxSteps {
        state.FinalAnswer = step.FinalAnswer
        break
    }
}

关键优化:

  • Steps截断 state.Steps[:min(len(state.Steps), 5)] 确保Prompt永远不超过token限制。我们测试过,超过7步历史,模型就开始胡编“之前我说过...”。
  • 原地追加 append 在底层数组容量足够时不分配新内存,实测1000步内存增长<2MB。
  • 错误聚合 state.addError() 把错误存入 Steps 末尾,而非中断循环——这样即使某步失败,后续步骤仍可继续。

3.3 Function Calling执行器:如何让工具调用像HTTP请求一样可靠?

Function Calling的可靠性不取决于模型,而取决于执行器的设计。一个健壮的执行器必须解决三个问题: 参数校验、沙箱隔离、失败降级

我们以调用本地Shell工具为例(如 shell_exec("ls -l ~/Downloads") ):

type ShellTool struct {
    timeout time.Duration
    cache   *fastcache.Cache
}

func (t *ShellTool) Execute(ctx context.Context, input json.RawMessage) (string, error) {
    // 1. 参数校验:必须是合法JSON且含command字段
    var req struct {
        Command string `json:"command"`
        Timeout int    `json:"timeout,omitempty"`
    }
    if err := json.Unmarshal(input, &req); err != nil {
        return "", fmt.Errorf("invalid JSON: %w", err)
    }
    if req.Command == "" {
        return "", errors.New("command is required")
    }

    // 2. 沙箱控制:禁止危险命令
    dangerous := []string{"rm -rf", "sudo", "curl http://", "wget "}
    for _, d := range dangerous {
        if strings.Contains(req.Command, d) {
            return "", fmt.Errorf("command blocked: %s", d)
        }
    }

    // 3. 超时控制与缓存
    ctx, cancel := context.WithTimeout(ctx, t.timeout)
    defer cancel()
    
    cacheKey := fmt.Sprintf("shell:%s", md5.Sum([]byte(req.Command)).String())
    if cached, ok := t.cache.Get(nil, cacheKey); ok {
        return string(cached), nil
    }

    // 4. 执行并捕获输出
    cmd := exec.CommandContext(ctx, "sh", "-c", req.Command)
    var out, errOut bytes.Buffer
    cmd.Stdout, cmd.Stderr = &out, &errOut
    err := cmd.Run()

    result := out.String()
    if err != nil {
        result = fmt.Sprintf("Error: %v\n%s", err, errOut.String())
    }
    
    // 缓存结果(仅成功时)
    if err == nil {
        t.cache.Set(cacheKey, []byte(result), 60)
    }
    
    return result, nil
}

这个执行器的价值在于:

  • 参数校验前置 :在调用 exec.Command 前就拦截非法输入,避免进程级风险;
  • 沙箱白名单 dangerous 数组可动态加载,运维可随时禁用新命令;
  • 缓存智能 :只缓存成功结果,失败不缓存,防止“永久性错误”污染。

提示:别用 os/exec CombinedOutput !它会把stdout/stderr混在一起,导致LLM无法区分“命令输出”和“错误信息”。必须分开捕获,然后按 "Output: ...\nError: ..." 格式返回。

3.4 流式输出与事件总线:为什么CLI助手必须支持实时反馈?

个人助手的用户体验生死线在于 响应感 。用户输入“帮我生成周报”,如果3秒后突然弹出2000字文档,他会觉得卡顿。正确体验是: > 生成中... > 正在整理会议记录 > 已提取3个关键结论 > 周报生成完成

这需要Event Bus架构。我们定义标准事件:

type AgentEvent struct {
    Type      EventType `json:"type"`      // "thought", "action", "observation", "final_answer"
    Content   string    `json:"content"`   // 人类可读内容
    StepID    int       `json:"step_id"`   // 关联步骤ID
    Timestamp time.Time `json:"timestamp"` // 事件发生时间
}

type EventType string
const (
    EventThought     EventType = "thought"
    EventAction      EventType = "action"
    EventObservation EventType = "observation"
    EventFinal       EventType = "final_answer"
    EventError       EventType = "error"
)

// RunStream返回事件通道
func (a *Agent) RunStream(ctx context.Context, goal string) (<-chan AgentEvent, error) {
    events := make(chan AgentEvent, 100) // 缓冲区防阻塞
    
    go func() {
        defer close(events)
        
        state := &AgentState{Goal: goal, StartTime: time.Now()}
        for stepID := 1; stepID <= a.maxSteps; stepID++ {
            // 发送Thought事件
            events <- AgentEvent{
                Type:    EventThought,
                Content: fmt.Sprintf("思考如何达成目标:%s", goal),
                StepID:  stepID,
            }
            
            // 执行Loop逻辑...
            step := executeOneStep(state, stepID)
            
            // 发送各类事件
            switch {
            case step.Thought != "":
                events <- AgentEvent{Type: EventThought, Content: step.Thought, StepID: stepID}
            case step.Action.Tool != "":
                events <- AgentEvent{Type: EventAction, Content: step.Action.Tool, StepID: stepID}
            case step.Observation != "":
                events <- AgentEvent{Type: EventObservation, Content: truncate(step.Observation, 200), StepID: stepID}
            case step.Error != "":
                events <- AgentEvent{Type: EventError, Content: step.Error, StepID: stepID}
            case state.FinalAnswer != "":
                events <- AgentEvent{Type: EventFinal, Content: state.FinalAnswer, StepID: stepID}
            }
        }
    }()
    
    return events, nil
}

CLI端消费事件:

# > ./agent-cli "总结我今天的会议"
> 思考如何达成目标:总结我今天的会议
> 正在调用calendar工具获取今日会议
> 已获取3场会议:10:00 产品评审,14:00 技术方案讨论...
> 正在调用transcribe工具转录10:00会议
> 已提取关键结论:1. 确认Q3上线时间;2. 分配前端开发任务...
> 周报生成完成

注意: truncate(step.Observation, 200) 至关重要。某次测试中, get_tasks 返回了10000行日志,Event Bus直接OOM。现在所有 Observation 强制截断,前端显示“...(更多内容见日志)”。

4. 生产级加固:状态持久化、错误恢复与性能调优

4.1 本地状态持久化:为什么SQLite不是最佳选择?

很多教程推荐用SQLite存 AgentState ,但个人助手场景下这是灾难。SQLite的ACID特性在单机场景是冗余的,而其 写锁机制会导致高并发下严重阻塞 。我们实测:当10个CLI实例同时运行,SQLite写入延迟从2ms飙升至800ms。

正确方案是 分层存储

  • 内存层 AgentState 全程驻留内存,保证Loop速度;
  • 文件层 :每步完成后,异步写入 ~/.agent/state.json (JSON Lines格式);
  • 索引层 :用BoltDB存 goal_id → file_offset 映射,实现毫秒级检索。

state.json 样例(JSON Lines):

{"goal_id":"g1","step_id":1,"event":"thought","content":"查询今日待办","ts":"2024-05-20T10:00:00Z"}
{"goal_id":"g1","step_id":1,"event":"action","content":"get_tasks","ts":"2024-05-20T10:00:01Z"}
{"goal_id":"g1","step_id":1,"event":"observation","content":"共3项待办","ts":"2024-05-20T10:00:02Z"}

优势:

  • 零锁写入 :每行独立, echo '{"goal_id":"g1",...}' >> ~/.agent/state.json 原子操作;
  • 快速回溯 tail -n 100 ~/.agent/state.json | grep '"goal_id":"g1"' 即得完整轨迹;
  • 磁盘友好 :1000步日志仅占200KB,远小于SQLite的MB级文件。

实操心得:务必加 fsync !我们曾因断电丢失最后10步日志。正确写法:

f, _ := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
f.Write([]byte(line + "\n"))
f.Sync() // 强制刷盘

4.2 错误恢复机制:当工具调用失败时,Agent如何自救?

Agent的健壮性不体现在“永不失败”,而在于“失败后如何优雅降级”。我们设计三级恢复策略:

失败类型 恢复动作 示例
工具超时 切换备用工具或返回缓存 calendar 超时 → 读取 ~/.agent/cache/calendar.json
参数错误 反思后重试,修正参数 search("北京天气") 返回400 → 反思 "应添加城市编码" → 重试 search("北京_101010100")
永久失败 记录错误并移交人工 shell_exec("rm -rf /") 被沙箱拦截 → 返回 "权限不足,请手动操作"

核心是 Reflection 环节。我们不依赖模型生成反思,而是用规则引擎:

func (a *Agent) reflect(ctx context.Context, state *AgentState, err error) string {
    lastStep := state.Steps[len(state.Steps)-1]
    
    switch {
    case errors.Is(err, context.DeadlineExceeded):
        return fmt.Sprintf("工具 %s 超时,尝试降级为本地缓存", lastStep.Action.Tool)
    case strings.Contains(err.Error(), "invalid JSON"):
        return fmt.Sprintf("参数格式错误,检查 %s 的输入结构", lastStep.Action.Tool)
    case strings.Contains(err.Error(), "permission denied"):
        return fmt.Sprintf("权限不足,建议手动执行:%s", lastStep.Action.Tool)
    default:
        return "执行失败,请检查输入或重试"
    }
}

这个设计让Agent具备“确定性反思”——不靠模型瞎猜,而是基于错误类型精准响应。上线后,工具调用失败后的用户放弃率从65%降至12%。

4.3 性能调优实战:如何让Loop单步稳定在300ms内?

个人助手的响应阈值是300ms。超过此值,用户会感知卡顿。我们通过四层优化达成目标:

第一层:Prompt精简

  • 移除所有注释性文字:“You are a helpful AI assistant...”
  • 用占位符压缩历史:“[STEP1] 查询邮箱 → [STEP2] 解析邮件 → ...”
  • 实测:Prompt从1200 token减至320 token,LLM响应快2.1倍。

第二层:工具并行化 对无依赖步骤启用并发:

// Plan-and-Execute模式下
batches := topologicalSort(plan) // 拓扑排序分批
for _, batch := range batches {
    var wg sync.WaitGroup
    for _, step := range batch {
        wg.Add(1)
        go func(s PlanStep) {
            defer wg.Done()
            s.Result = executeTool(s.Tool, s.Input)
        }(step)
    }
    wg.Wait()
}

第三层:本地缓存穿透 为高频工具加二级缓存:

  • L1:内存Map(1000条,TTL=10s)
  • L2:磁盘BoltDB(10万条,TTL=1h)

第四层:LLM降级策略 当OpenAI超时,自动切到本地小模型:

func (a *Agent) getLLMClient() LLMClient {
    if time.Since(a.lastSuccess) < 5*time.Minute {
        return a.openaiClient
    }
    return a.llamaClient // 本地Llama3-8B,响应<800ms
}

最终效果:在M2 Mac上,95%的Loop单步耗时≤280ms,P99≤410ms。用户实测反馈:“比Siri还跟手”。

5. 常见问题排查:那些让你熬夜到凌晨三点的坑

5.1 “Agent反复调用同一个工具”问题溯源表

这是最高频的线上故障。现象:用户问“查天气”,Agent连续10次调用 weather_tool ,直到超时。根本原因从来不是模型,而是以下三类:

问题类型 表现特征 排查命令 修复方案
Prompt缺失终止条件 模型输出 Thought: 需要更多天气数据 ,但Prompt里没写“当数据足够时必须输出Final Answer” grep -A5 "Final Answer" prompt.txt 在Prompt末尾强制添加:“必须且只能在满足用户目标时输出Final Answer,否则必须输出Thought/Action”
Observation信息不足 weather_tool 返回 {"temp":25} ,但LLM需要“是否需要带伞”,而 Observation 没提供湿度/降水概率 tail -n 20 ~/.agent/state.json | grep observation 修改工具: return {"temp":25, "humidity":80, "precipitation":"60%"}
状态未更新 state.Steps 长度始终为0,每次都是全新state printf "%p\n" &state.Steps (检查地址是否变化) 确保 state.Steps = append(state.Steps, step) ,而非 state.Steps = []Step{step}

真实案例:某次发布后,Agent疯狂调用邮件工具。查日志发现 Observation 里只有 "mail_count": 12 ,但Prompt要求“列出所有邮件主题”。修复:工具返回 {"count":12, "subjects":["会议纪要","合同扫描件",...]}

5.2 “Function Calling不触发”问题速查清单

llm.Chat 返回 tool_calls: [] ,说明模型拒绝调用工具。按此顺序排查:

  1. 检查Tool Definition是否注册
    fmt.Printf("Registered tools: %+v\n", agent.tools.List())
    → 若为空,检查 agent.WithTools(...) 是否漏传。

  2. 验证Parameters Schema是否合法

    {
      "type": "object",
      "properties": {
        "query": {"type": "string"}
      },
      "required": ["query"]
    }
    

    → 若 required 缺失,部分模型(如Claude)会忽略该工具。

  3. 确认Prompt中工具描述是否清晰
    错误:“Use weather to get temperature”
    正确:“weather: 获取指定城市的实时温度、湿度、降水概率。输入必须是{'city': '北京'}格式”。

  4. 检查模型是否支持Function Calling
    OpenAI GPT-3.5-turbo-0613支持,但 gpt-3.5-turbo 别名可能指向旧版。
    → 强制指定 model: "gpt-3.5-turbo-0613"

5.3 CLI助手“输入卡死”问题终极解决方案

用户输入后光标一直闪烁,无任何输出。90%是以下原因:

  • stdin 缓冲区阻塞 bufio.Scanner 默认4KB缓冲,长输入会卡住。
    → 改用 bufio.NewReader(os.Stdin) + ReadString('\n')

  • 事件通道未消费 RunStream 返回的channel未及时读取,导致goroutine阻塞。
    → 在CLI主循环中,必须用 for event := range events { ... } ,不可用 select 单次读取。

  • LLM超时未设context llm.Call() 未传 ctx ,导致 Ctrl+C 无法中断。
    → 所有LLM调用必须 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)

最后分享一个血泪教训:某次上线后,用户反馈“输入后没反应”。查日志发现 state.json 里全是 {"event":"thought","content":"..."} ,但没有 action 事件。最终定位到是 parseStep() 函数里,模型返回了 Action: search (带空格),而正则 ^Action: (.+)$ 没匹配上。 永远不要相信模型输出的格式 ——要么用Function Calling,要么对Text-based ReAct做容错匹配: strings.TrimSpace(strings.TrimPrefix(line, "Action: "))

6. 个人助手进阶:从CLI到桌面应用的平滑演进

6.1 架构演进路线图:如何避免重写三次代码?

很多团队踩坑:先做CLI,再做Web,最后做桌面App,每次都要重写Agent核心。正确路径是 分层抽象

┌─────────────────┐    ┌──────────────────┐    ┌────────────────────┐
│   CLI Interface │───▶│  Agent Core      │◀───┤  Web Interface     │
│  (stdin/stdout) │    │  (Loop + State)  │    │  (SSE + WebSocket) │
└─────────────────┘    └────────┬─────────┘    └────────────────────┘
                                  │
                      ┌───────────▼───────────┐
                      │  Tool Execution Layer │
                      │  (Shell, Calendar...) │
                      └───────────────────────┘

关键在 Agent Core 层完全无I/O依赖。它只接收 goal string ,返回 <-chan AgentEvent 。CLI/Web/Desktop只是不同的“皮肤”。我们用此架构,将CLI版升级为Hermes Agent桌面版仅用2天:

  • CLI版: main.go 里调用 agent.RunStream(...) fmt.Print(event.Content)
  • 桌面版: electron/main.js 里调用 spawn('./agent-core', [goal]) ,监听stdout解析JSON Lines

提示:桌面版必须解决 跨平台工具路径 。CLI里 shell_exec("open -a Notes") 在Mac有效,在Windows会失败。正确做法是工具注册时声明平台:

NewTool("notes", ToolConfig{
    Mac:  "open -a Notes",
    Win:  "start notepad.exe",
    Linux: "gedit",
})

6.2 多Agent协作雏形:当个人助手需要“叫帮手”时

高级个人助手必然面临能力边界。比如“帮我分析这份PDF合同”,但Agent本身不支持PDF解析。此时需引入 协作Agent

// 主Agent发现需要PDF分析时,启动子Agent
if strings.Contains(goal, "PDF") {
    pdfAgent := NewSubAgent("pdf-analyzer")
    result, _ := pdfAgent.Run(ctx, fmt.Sprintf("extract clauses from %s", pdfPath))
    state.Metadata["pdf_analysis"] = result
}

子Agent的关键设计:

  • 独立状态 pdfAgent.state 与主Agent隔离,避免污染;
  • 超时熔断 pdfAgent.WithTimeout(15*time.Second) ,超时则返回“PDF过大,建议分页处理”;
  • 结果注入 :分析结果存入 state.Metadata ,主Agent在下一步Prompt中可引用 {{.Metadata.pdf_analysis}}

这为未来接入Hermes Agent、Pi Agent等第三方智能体预留了标准接口。不需要改核心Loop,只需在 parseStep() 里识别 tool: "hermes_analyze" ,然后调用其API。

6.3 安全红线:个人助手绝不能触碰的五个禁区

最后,用血的教训划清安全底线(这些都在我们线上事故复盘中真实发生过):

  1. 绝不执行用户输入的任意代码
    → 即使用户说“运行`rm -
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐