希望能够在这篇文章中综合讨论搭建代理的各种方案,这里的代理定义并不局限于能力边界要求,而是从普通的工作流一直到大规模skill/框架搭建出的Agent。一个合理的Agent不应该盲目的增加复杂度,而应该根据任务的场景、ROI等等因素来衡量方案
工作流
对于工作流,市面上有很多成熟的框架例如n8n coze Dify等等,目前我只用过n8n,所做的工作也比较简单。主要原因是我的场景中并没有太多完全流程化的东西,而工作流的方案往往更适合与这些。
它的基本步骤就是首先要提出一个SOP的流程,然后选一个基模,我认为这里尤其应该注意的是,Deepseek是没有多模态能力的,而是通过OCR来实现图片识别,在搭建工作流时应该权衡经济和多模态能力,是否选择Deepseek。而搭建一个工作流的麻烦点就是各个节点的配置,举一个基础的例子就是搭建邮件工作流,如果是谷歌邮箱,其中的Oauth流程还是挺复杂的。有一个SOP流程+节点配置好,实际上一个工作流就不难搭建了。而对于不同的任务,需要在以上提到的框架中多找找,可能某个框架针对这个任务场景设计的节点要更加优雅和安全。
同时大多数工作流工具主要是DAG,虽然支持Loop,但是远远没有代码灵活。
Agent 框架
当然了框架肯定不仅限于LAngchain团队开发的,还有CrewAI\ 另外还关注到了技术群里一个人开发的生态瓶框架等等。只是LangChain的接口更加通用, 即使并不好用..
相较于Raw SDK, LangChain框架统一了接口,提供了持久化存储,包括checkpoint和threadID接口,能够实现历史回溯。但是Langchain的封装太高。例如使用creatAgent一个接口完成了整个agent创建,因此这里解释更加自由的Langgraph。SDK 中的 while 循环虽然简单,但难以持久化。LangGraph 将流程定义为 Nodes(节点/动作) 和 Edges(边/逻辑跳转)。具体可以看一下文档,Langchaint团队还开发了很多Agent的接口特性。使用Langchain快速搭建一个agent 完成一些基本验证是没问题的,但是欠下来的技术债也是很可观的. 所以接下来首先说一下其弊端.
过度封装
如果你尝试去 Debug 一个稍微复杂的 LangChain 链路,你会陷入无穷无尽的底层源码跳转。一个简单的提示词拼接和模型调用,中间可能穿插了 BasePromptTemplate -> StringPromptTemplate -> PromptTemplate 等五六层继承。
ConversationalRetrievalChain 这种高级链,一行代码就把历史记录、向量检索、Prompt 拼接、大模型调用全部打包了。当模型输出不符合预期时,你根本不知道是检索阶段出了问题,还是内部隐式拼接的 Prompt 破坏了上下文,或者是内置的 Output Parser 解析失败。你想改动其中一个微小的逻辑,就必须去继承并重写它极其复杂的底层类。
为什么 LangChain 要把 LCEL、提示词、追踪做得到处都是抽象,让你无法轻易看清数据流?(道听途说)可能是 LangSmith。因为 LangChain 底层复杂的封装和 LCEL 带来的 Debug 灾难,开发者在本地几乎无法调试。这时候,官方就会推荐你接入 LangSmith。
LangeGraph
Langchain团队目前已经有了很多产品,包括 langchain langgraph,langsmth langserver等,不具体介绍,但是langchain是一个DAG, 甚至不能够完成一个agentloop, 所以从构建agent角度而言,重点谈langgraph
下面是一段使用langGraph改造的代码
1 | import json |
Langgraph提供的其他API
human in the loop
通过 interrupt_before 或 interrupt_after 来暂停图的执行,等待人工确认或修改状态。实例:
1 | # ... (前面的代码保持不变) ... |
checkpoint与threadID
在Langgraph中通过快照策略将每个操作保存为一个checkpoint,将每一个会话保存为一个threadID,可以通过数据库以及序列化保存这个快照,并设计逻辑实现查找历史会话或者回溯历史checkpoint。而当返回某个checkpoint进行新的操作时,会被分支到一个新的checkpoints,而不是直接覆盖。关于threadID和checkpoint的过滤、排序等策略,都可以在数据库中实现。
subagent
在 LangGraph 中实现层级 Agent 的思路,可以定义另一个 StateGraph (比如 stream_monitor_graph),编译成 monitor_app,然后把它作为一个 Node 放入你的主图 workflow 中。
上下文压缩
虽然 operator.add 导致了状态的无限增长,但 LangGraph 并非束手无策。对于简单的场景,我们在 Node 内部使用 trim_messages 进行基于 Token 的动态截断;而对于需要长期记忆的场景,我们可以设计一个专门的 SummarizeNode,利用 LangGraph 特有的 RemoveMessage 接口,定期对 State 进行上下文压缩。
一个简单的sumarizeNode设计
1 | from langchain_core.messages import RemoveMessage, SystemMessage, HumanMessage |
或者使用官方的trim工具
1 | from langchain_core.messages import trim_messages |
SDK实现
我认为目前agent的设计形态还没有收敛,很多工程细节处理上都没有一个共识,所以与其让agent框架束手束脚,不如自己从头造轮子, 更何况我认为很多轮子是没比框架复杂多的. 对于一个agent而言,最核心的就是如何进行循环, 参考langgraph,是使用一个State来管理的,通过将message不断的append来管理上下文.
进入循环之前还要引入预处理,模型的上下文是有限制的, 用户的经费是有限制的, 注意力并不是全局平均的,因此如何对放入循环的全部信息进行处理就是一门学问,毫无疑问需要的是裁剪->压缩. 在循环之中要进行重试机制设计,以及简单的错误处理,因为即使模型经过了sft,但是我认为也不能够保证格式的百分百正确,做一些基础冗余是必要的,另外还要实现消息的流式传递,改善用户体验. 单次的query结束之后应该进行进一步判定,根据sdk提供的字段信息,是需要调用工具,还是返回了超出窗口等基本错误,或者单纯的结束. 依据此来选择continue路径. 完成queryloop之后仍然应该继续处理, 可以设立一些钩子,对本次会话或者其他信息总结,可以总结到项目级别的spec或者全局级别的Memory.md中.
除了循环系统,工具系统设计也是至关重要, 对tool设立一个标准的schema\权限级别\统一的调用接口\facade调用层, 都是必不可少的.除了这些基本接口还应该通过结构字来设立并行的执行能力, 但是要有所权衡.在工具调用的每个batch中判断并执行.
上下文的构建也很关键, 我认为由于transformer的特点,第一条消息应该注入全局AGENTS.md 以及项目级别的AGENTS.md. 另外还应该选择注入当前环境的环境变量等信息,来帮助模型完善上下文. 在单次loop中的不同query中也应该使用state来追加消息,避免丢失上下文,即使api侧可能提供缓存,但是agent设计侧不应该轻信这一点,即使token的耗费较大.通过不断的追加以及循环中的裁剪->压缩来完成一个可控的多轮任务执行.
除了以上基本信息,LSP引入\MCP引入\搜索机制设计\多agent机制\skill加载 也是不可或缺的. LSP client能够帮助agent 获得更加丰富的代码上下文, MCP client可以获得更加丰富的能力,多agent可以更优雅的实现分离的互不干扰的上下文.skill通过分层设计skill.md实现了一种渐进式加载的策略,为agent提供一个标准的SOP工作流程, 关于skill设计可以看官方博客
这个部分会一直写下去,当我在agent设计方面有一些新的思路的时候..
skill.md设计
linuxdo上有一篇文章写的很好,以下是一个案例
1 | --- |
沙箱机制
对于沙盒机制, 其技术实现方案较为复杂, 大致路线分为云侧和本地,
云侧
在云侧, ai需要一个快速启动,快速删除的沙箱,因此冷启动速度至关重要,主要的技术方案有基于虚拟机\ docker \ wasm的,另外这篇文章还谈论了关于unikernel在沙箱实现上的可能性. 云侧虚拟机技术各类机制的原理\方案这篇文章,也有描述. 由于我没有做过云侧的沙盒,这里仅仅列下和ai探讨的恶性能比较
| 评估维度 | 系统进程级限制 (Seccomp) | 传统容器 (Docker) | 运行时沙箱 (gVisor / WASM) | 微型虚拟机 (Firecracker) |
|---|---|---|---|---|
| 隔离边界 | 进程级、系统调用级限制 | 操作系统级、共享内核隔离 | 用户态虚拟内核/指令级隔离 | 硬件级、独立内核强隔离 |
| 启动延迟 | $< 1\text{ ms}$ | $100\text{ ms} - 500\text{ ms}$ | WASM ($<1\text{ ms}$) / gVisor ($200\text{ ms}$) | $100\text{ ms} - 200\text{ ms}$ |
| 宿主性能损耗 | $\approx 0%$ | $\approx 1% - 3%$ | WASM (低) / gVisor ($10% - 30%$) | $\approx 3% - 5%$ |
| 多语言生态兼容 | 极差(难以配置普适策略) | 极佳(利用 Dockerfile 生态) | WASM (差) / gVisor (极佳) | 极佳(完整的操作系统生态) |
| 防逃逸能力 | 低(无法抵御内核提权) | 中(存在已知漏洞逃逸风险) | 高(切断物理内核调用) | 最高(硬件级锁死安全边界) |
本地
而在本地, 由于agent客户端应该充分接触项目,并访问本地文件,完成搜索, 权限管理等操作, 这些操作灵活而且难以归类, 同时要求依赖较少,因此使用系统的运行时限制就变得更为合适, 而进程级别的限制复杂处就在于系统的适配,对于linux, 可以使用bubblewrap 对于win,需要使用win32的API. 我在本地沙盒工具的实现是绑定到了bash工具上,通过参数的直接叠加完成系统的适配, 但是bash的网络权限配置是一个难以tradeoff的点,观察claudecode 和codex的实现,为了减少适配难度和依赖,他们都没有对bash的网络权限做限制, 因此我也暂时没有实现这一点,但是实际上这是可以被解决的,具体方案如下
- 接口层:强制声明网络意图 (Declarative Schema)
修改大语言模型(LLM)调用的 execute_bash 工具定义。取消 LLM 的隐式网络特权,强迫其在执行命令前,以结构化方式准确声明网络访问的边界。
1 | { |
- 网络层:沙箱内流量强制劫持 (Traffic Hijacking)
绝不能让 bwrap 沙箱直接共享宿主机的网络命名空间。
隔离配置:启动沙箱时,关闭默认路由(Linux 下为 bwrap –unshare-net,结合 slirp4netns 提供隔离的用户态网络栈)。
代理注入:在 Go 宿主代理中实现一个极轻量的 HTTP/HTTPS 正向代理服务器(监听在 127.0.0.1 上的随机高端口)。在启动沙箱子进程时,注入环境变量 HTTP_PROXY 和 HTTPS_PROXY,强行将沙箱内所有工具(curl, git, go, pip)的出站流量导向 Go 宿主代理。
- 控制层:分级鉴权策略 (Tiered Authorization)
当沙箱内的命令发起网络请求时,流量会抵达 Go 宿主代理。Go 代理拦截 TLS SNI(服务器名称指示)或 HTTP Host 头,并依据以下分级策略执行判定:
拦截规则 A(绝对阻断 SSRF):
硬编码丢弃所有指向本地环回地址(127.0.0.0/8)和局域网私有地址段(192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12)的请求。这从物理上杜绝了 Agent 恶意扫描宿主机本地服务或企业内网的可能性。
拦截规则 B(静态基建白名单放行):
维护一个软件工程基础设施白名单库(如 github.com, proxy.golang.org, registry.npmjs.org, pypi.org, archive.ubuntu.com)。如果请求的域名命中该库,静默放行,保证标准包管理工具的顺畅运行。
拦截规则 C(非标准域名的人机交互审计 Human-in-the-loop):
如果 LLM 要求访问非标准域名(例如通过 wget 下载某个学术网站的数据集),Go 宿主代理挂起该网络请求,并在终端(或 UI)向当前登录用户抛出提示:
“Agent 正在尝试访问 unknown-domain.com(理由:下载测试数据集)。是否允许?(Y/N)”
用户确认后,该域名被加入当前会话的动态白名单并放行;若拒绝,向沙箱返回 TCP 连接重置(RST),导致 bash 命令报错中断。
而对于win的适配,在 Windows 环境下,Spec 的映射需要组合多个 Win32 内核对象。通过串联 强制完整性控制 (MIC)、访问令牌 (Access Token) 与 作业对象 (Job Object) 三个内核机制,构建一个受限执行流。
整体实现分为四个时序阶段:
- 环境准备阶段:建立合法写入通道, Windows 默认的文件系统完整性级别为 Medium(中等)。沙盒进程将被降级为 Low(低),根据“不向上写(No Write Up)”的安全策略,沙盒将失去对整块硬盘的写入权限。
- 动作:在 Agent 发起执行任务前,宿主程序必须定位或创建本次任务专属的
Workspace目录。 - 实施:通过外部系统命令(
icacls <路径> /setintegritylevel (OI)(CI)L)或底层的 ACL API,显式将该目录的完整性级别降低为 Low IL,并设置子目录和文件的继承标志。这是沙盒进程唯一合法的物理输出出口。
- 上下文构建阶段:颁发受限凭证, 宿主代理(Go 程序)需要为其即将启动的子进程伪造一张权限被大幅削减的“身份证”。
- 动作 1(剥离特权):调用
OpenProcessToken获取宿主当前的中等完整性令牌。调用CreateRestrictedToken,传入DISABLE_MAX_PRIVILEGE,硬性剥离诸如关机、系统环境修改、调试等所有管理员特权。 - 动作 2(强制降级):构造一个标识为
SECURITY_MANDATORY_LOW_RID的安全标识符(SID)。调用SetTokenInformation,将受限令牌的完整性级别(Integrity Level)强制覆盖为 Low IL。
- 边界确立阶段:生命周期强制绑定
大模型生成的动态脚本(如长期挂起的 Web Server 或陷入死循环的编译任务)极易产生难以回收的孤儿进程。
- 动作:调用
CreateJobObject创建一个作业对象。 - 实施:调用
SetInformationJobObject,为该作业对象注入JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE限制策略。此策略向内核声明:一旦持有该作业对象句柄的宿主进程(Go 代理)退出,内核必须立即无条件销毁该作业对象内包含的所有进程树。
- 进程注入与执行阶段:安全点火与通信
必须消除“进程启动”与“权限限制生效”之间的时间差(Time-of-Check to Time-of-Use 竞争条件)。
- 动作 1(挂起启动):使用步骤 2 生成的受限令牌,调用
CreateProcessAsUser启动目标命令(如pwsh.exe -Command ...)。必须设置CREATE_SUSPENDED标志位。此时进程已在内存中创建,但主线程被内核冻结,尚未执行任何用户态代码。 - 动作 2(装载隔离罩):调用
AssignProcessToJobObject,将处于冻结状态的子进程句柄塞入步骤 3 创建的作业对象中。 - 动作 3(唤醒与 I/O 接管):将子进程的标准输出(Stdout)和标准错误(Stderr)重定向至 Go 宿主预先创建的匿名管道。最后调用
ResumeThread唤醒主线程,开始实际执行大模型下发的指令。