Agent设计:纯推理句柄与状态分离
前言agent 组件是 nexus agent 核心层的枢纽——所有能力在这里装配所有执行形态从这里发起。这篇讲它的形状为什么是这样。Agent——nexus 里的纯推理句柄。一句话定位只持能力不持状态。能力是 llm、tools、skills、interceptors、system prompt、maxSteps、mode状态会话历史、压缩、memory一个都不在它身上全在 AgentSession。这条边界是整个 nexus 最重要的一条。Agent 可以被任意多个 AgentSession 复用—— 100 万用户的客服系统按角色建几个 Agent账单、技术、退款每个用户一个 Session共享同一套 Agent 配置和模型 client。如果状态长在 Agent 上每个用户都得 new 一个模型 client 无法池化配置无法共享。核心设计本质上agent是一个粘合层负责把各种能力结合起来转发给LLM。agent的核心模型// Agent 是纯推理句柄只持能力llm/tools/skills/interceptors/system/maxSteps// 不持会话状态。一个 Agent 可跨多个 AgentSession 复用。// Run/Resume 走 reAct loop无 tools 时第一轮即退出等价 direct 单次调用// 有 tools 时多轮 tool 调用直至 stop。plan-execute 范式用 PlanExecAgent。type Agent struct {name stringdesc stringSystem stringllm llm.LLMClientskills *Skillstools []tools.TooltoolMap map[string]tools.TooltoolInterceptor []tools.ToolInterceptorllmInterceptor []llm.LLMInterceptorhooks *hooks.HooksmaxSteps intmaxPlanSteps intmode AgentMode}从核心模型能看到agent基本只有几个核心概念的组装外加tool和llm拦截器组成。初始化支持两种方式// NewAgent 轻量入口func NewAgent(name, system string, l llm.LLMClient, opts ...Option) *Agent {cfg : AgentConfig{Name: name, System: system, LLM: l}for _, o : range opts {o(cfg)}return NewAgentWithConfig(cfg)}// NewAgentWithConfig 重度/配置驱动入口直接传 AgentConfig。// 适合参数多、或从序列化配置加载的场景。func NewAgentWithConfig(cfg *AgentConfig) *Agent {cfg.apply()a : Agent{name: cfg.Name,desc: cfg.Desc,System: cfg.System,llm: cfg.LLM,tools: cfg.Tools,toolMap: make(map[string]tools.Tool, len(cfg.Tools)),skills: cfg.Skills,hooks: cfg.Hooks,toolInterceptor: cfg.ToolInterceptors,llmInterceptor: cfg.LLMInterceptors,maxSteps: cfg.MaxSteps,maxPlanSteps: cfg.PlanMaxSteps,mode: cfg.Mode,}if a.skills ! nil {a.System a.skills.Prompt(a.System)a.tools append(a.tools, a.skills.skillTools()...)}for _, t : range cfg.Tools {a.toolMap[t.Schema().Name] t}if a.hooks ! nil {a.toolInterceptor append(a.toolInterceptor, a.hooks.HookInterceptor)}return a}NewAgent比较轻量通过option的方式而NewAgentWithConfig则是直接通过配置构造这里值得额外说一句的是可以看到skill是如何构造并且注入到agent和llm的通过agent的system prompt加上自己的skill 重写了原来的system prompt也许不重写只是在run的时候构造更合理然后把skill 转化成tool给llm调用重写逻辑func SkillSystemPrompt(prompt string, ss ...*Skill) string {if len(ss) 0 {return prompt}var b strings.Builderb.WriteString(Available skills — if one seems relevant to the task, call read_skill(name) to load its full instructions before proceeding:\n)for _, s : range ss {b.WriteString(s.Summary()) // - name: desc\n}return b.String()}这是默认逻辑业务也可以自己替换type SkillOptions struct {systemPrompt skills.SystemPromptFuncloader skills.Loader}func (o *SkillOptions) apply() {if o.systemPrompt nil {o.systemPrompt skills.SkillSystemPrompt}if o.loader nil {o.loader skills.SkillLoader(skills.DirLoader)}}type SkillOption func(opts *SkillOptions)func WithSystemPrompt(systemPrompt skills.SystemPromptFunc) SkillOption {return func(opts *SkillOptions) { opts.systemPrompt systemPrompt }}func WithLoader(loader skills.Loader) SkillOption {return func(opts *SkillOptions) { opts.loader loader }}// Skills 支持skill注入agent。构造时加载并过滤掉依赖未满足的 skill// Prompt 把 skill 摘要含 read_skill 指令头拼进 system prompt// skillTools 暴露 read_skill / read_skill_resource 两个工具供 LLM 按需加载 skill 全文与资源。type Skills struct {systemPrompt skills.SystemPromptFuncloader skills.Loaderskills []*skills.Skill}还有就是通过拦截器把hook注入到每一次tool的调用进行拦截这样我们就可以很轻松实现比如pause审批human in the loop的能力。func (h *Hooks) HookInterceptor(next tools.ToolHandler) tools.ToolHandler {return func(ctx context.Context, params map[string]interface{}, s tools.Schema) (string, error) {for _, hook : range h.rules {if !hook.Matcher.Match(s, params) {continue}switch hook.Action {case ActionDeny:return hook.Reason, nilcase ActionAsk:// 命中规则 执行审批动作decision, err : h.fn(ctx, hook, params, s)if err ! nil {return , err}if decision.Action ActionDeny {return decision.Feedback, nil}default:}}return next(ctx, params, s)}}agent 需要实现核心接口// Agents agent 对外接口type Agents interface {Name() stringDescription() string// Run 单次任务执行Run(ctx context.Context, req *AgentRequest) (resp *llm.LLMResponse, err error)// Resume 对轮对话长任务有状态执行Resume(ctx context.Context, sess *AgentSession, req *AgentRequest) (*llm.LLMResponse, error)}// AgentsStream 流式执行能力按需实现。*AgentReAct/PlanExec实现// TransferAgent/Workflow 后续按需补。type AgentsStream interface {AgentsRunStream(ctx context.Context, req *AgentRequest) (-chan AgentEvent, error)//ResumeStream(ctx context.Context, sess *AgentSession, req *AgentRequest) (-chan AgentEvent, error)}这里拆分了两个接口一个是chat模式接口一个支持stream模式接口Run 是无状态单发每次现拼 system user 两条消息跑完即弃。无 tools 时第一轮就退出等价一次普通 Chat有 tools 时多轮 tool 调用到 stop。这是问一句答一句的场景。RunStream 是流式版run() 在后台 goroutine 跑通过 ch chan- AgentEvent 推事件AgentText/Reason/ToolCall/ToolResult/Final/Paused。ch 是否为 nil 是区分流式/非流式的开关——同一个 run()两种取数。注意 defer close(event) 和 ErrPaused 透传暂停不是错误不推 Err 事件。Resume 是有状态多轮核心是Agent 纯推理不管理 session 生命周期。Loadrehydrate由调用方在 Resume 前调Agent 不操心Agent 只负责把 sess.Messages() 当历史拼进 messages、跑完把新消息 sess.Append 持久化。具体实现func (a *Agent) Name() string {return a.name}func (a *Agent) Description() string {return a.desc}func (a *Agent) RunStream(ctx context.Context, req *AgentRequest) (-chan AgentEvent, error) {event : make(chan AgentEvent, 16)go func() {defer close(event)// ReAct 模式事件已在 loop 里推AgentText/Reason/ToolCall/ToolResult/Final/Paused// 这里只兜底非 paused 的错误。resp 无需再推AgentFinal 已在 ReAct 里推。if _, err : a.run(ctx, req, event); err ! nil !errors.Is(err, ErrPaused) {sendEvent(ctx, event, AgentEvent{Err: err})}}()return event, nil}// Run 无状态单发走 reAct loop。无 tools 时第一轮即退出等价 direct 单次调用。func (a *Agent) Run(ctx context.Context, req *AgentRequest) (resp *llm.LLMResponse, err error) {return a.run(ctx, req, nil)}这里可以看到不管是stream还是chat我们核心是统一的所以run可以复用起来。func (a *Agent) run(ctx context.Context, req *AgentRequest, ch chan- AgentEvent) (resp *llm.LLMResponse, err error) {messages : []llm.Message{{Role: system, Content: a.System},{Role: user, Content: req.Input},}if req.Mode 0 {req.Mode a.mode}switch req.Mode {case ReAct:resp, _, err a.ReAct(ch).Run(ctx, req, messages)case PlanExec:resp, _, err a.Plan(ch).Run(ctx, req, messages)}return resp, err}ReAct() 和 Plan() 是工厂方法返回 ReActAgent / PlanExecAgent——真正的循环执行器。Agent 本身不实现循环它只装配能力、分发到执行器。这样新增范式比如 Reflexion、Tree of Thought只需加一个执行器 一个 Mode 常量Agent 主体不动。循环是策略装配是机制又一条边界。Resume 有状态调用相比Run有状态需要记录每一次过程所以引入agent session进行独立存储压缩等处理。sess每次都会把ReAct 产生的new messages存储到内部方便统一处理在框架上很干净。func (a *Agent) Resume(ctx context.Context, sess *AgentSession, req *AgentRequest) (*llm.LLMResponse, error) {if sess nil {return nil, ErrNoSession}history : sess.Messages()messages : make([]llm.Message, 0, len(history)2)messages append(messages, llm.Message{Role: system, Content: a.System})messages append(messages, history...)messages append(messages, llm.Message{Role: user, Content: req.Input})// 持久化 usersess.Append(ctx, llm.Message{Role: user, Content: req.Input})resp, newMsgs, err : a.ReAct(nil).Run(ctx, req, messages)if err ! nil {if errors.Is(err, ErrPaused) {// 暂停落盘已产生消息透传 ErrPaused 供调用方识别续跑时 session 历史完整sess.Append(ctx, newMsgs...)return nil, err}return nil, err}// 持久化 新产生的消息sess.Append(ctx, newMsgs...)return resp, nil}最终都会转移到一个emit封装这个helper函数帮助agent屏蔽底层llm的chat和stream接口差异// emit 单轮 LLM 调用chnil 走非流式 Chat过 llmInterceptorch!nil 走 Stream转发 delta。// 两种模式都返回完整 *LLMResponseStreamFinal 或 Chat交给 ReAct 按 FinishReason 决定下一步。// 注意stream 路径暂未走 llmInterceptor待 StreamInterceptor 落地后补。func (a *Agent) emit(ctx context.Context, msgs []llm.Message, schemas []tools.Schema, ch chan- AgentEvent) (*llm.LLMResponse, error) {if ch nil {return llm.Chain(a.llmInterceptor...)(a.llm.Chat)(ctx, msgs, schemas)}stream, err : a.llm.Stream(ctx, msgs, schemas)if err ! nil {return nil, err}for {select {case -ctx.Done():return nil, ctx.Err()case chunk, ok : -stream:if !ok {return nil, fmt.Errorf(stream closed without final)}if chunk.Err ! nil {return nil, chunk.Err}switch chunk.Type {case llm.StreamText, llm.StreamReason:// 转发 delta 给上层展示StreamTool 累积态不转发ReAct 执行前推 AgentToolCall避免重复sendEvent(ctx, ch, AgentEvent{Type: AgentEventTypeFromLLM(chunk.Type),Response: chunk.Response,})case llm.StreamFinal:// 单轮结束不推事件——交 ReAct 按 FinishReason 决定 AgentToolCall/AgentFinalreturn chunk.Response, nil}}}}最后总结设计可以压缩成一句Agent 是纯推理句柄——构造时接线skills/hooks 自动注入运行时分发ReAct/Plan 工厂执行形态三选一Run/RunStream/Resume流式非流式同构emit。所有状态都被推到 AgentSession所有策略都被推到执行器和 hooksagent.go 自己只保留装配 分发这一层最薄的机制。这层薄机制是 nexus 整个 agent 体系的支点。