我用 1 个月写了一个 Web AI Coding Agent,今天开源 —— code-artisan

7 阅读13分钟

项目地址github.com/lhz960904/c… 欢迎点个 star ⭐ 。

体验地址code-artisan-production.up.railway.app/

为什么做这个项目

过去很长一段时间我尝试使用了不少 Agent 框架——LangChain、Vercel AI SDK、Claude Agent SDK。感受是:这些工具封装得好了,可以很快开发出 Agent 应用,但这反而让我对底层发生了什么产生很多疑惑和不安。所以促使我想从零自己实现一遍,真正去理解 ReAct 循环、工具调用、上下文压缩、沙箱隔离等等与 Agent 开发相关的技术细节。

code-artisan 就是这个「自己写一遍」的产物。它是一个参考 bolt.new / v0.dev 的 Web AI Coding Agent——可以在浏览器里用自然语言创建、编辑、运行代码,支持实时预览、PTY 终端、MCP 工具市场、内置 Skills。

从 2026-03-27 第一个 commit 到今天(2026-04-27),整整 31 天、215 个 commit。现在它开源了,整体架构还算简洁,希望对同样在探索 AI Agent 的朋友有参考价值。

它能做什么

demo.gif

  • ReAct Agent 驱动:用自然语言描述需求,Agent 自主规划步骤、调用工具、执行命令、调试错误
  • 完整工具链:内置 9 个工具(read_file / write_file / str_replace / bash / glob / grep / ls / web_search / web_fetch),Agent 可以像开发者一样操作代码库
  • Skills 加速:内置全栈脚手架 Skill(Vite + React + Hono + Bun),告诉 Agent「搭一个全栈 demo」,它知道该怎么做
  • MCP 工具市场:一键安装 MCP Server,Agent 下一轮自动获得新工具,能力可以持续扩展
  • 实时预览 + PTY 终端:代码跑在 E2B 沙箱里,expose_port 工具一键把端口暴露给浏览器 iframe;Agent 和用户共用一套真实的 PTY 终端
  • 多模型支持:Anthropic 原生 + 任意 OpenAI 兼容网关(DeepSeek、Kimi 等),通过 LLM_BASE_URL 环境变量一键切换

技术架构全景

项目是一个 pnpm monorepo,分 5 个包,关注点彻底解耦:

职责核心依赖
@code-artisan/agent核心 SDK:Agent 类、tool loop、middleware、Sandbox 接口@anthropic-ai/sdkopenaizod
@code-artisan/backendAPI 层:Hono server、SSE、E2BSandbox、PTY 管理hono@e2b/code-interpreterdrizzle-orm
@code-artisan/frontendUI 层:Vite + React + Tailwind + shadcn/ui + TanStack Routerreact@tanstack/routerzustand
@code-artisan/cli终端版 Agent,纯 SDK 消费方ink
@code-artisan/shared跨包共享类型zod

image.png

核心模块设计

一、ReAct Agent Loop & 多模型支持

ReAct(Reasoning + Acting)是目前 Coding Agent 的核心范式:模型先思考、再调用工具、再根据工具结果继续思考,循环直到任务完成或没有新的工具调用。

code-artisan 没有使用任何 Agent 框架,所以自己实现了这个循环——核心逻辑大约 340 行 TypeScript

flowchart TD
    Start([Start]) --> A[用户消息入队]

    subgraph Loop[Agent 主循环]
        B[LLM 流式输出]
        C[提取 tool_use]
        D{有 tool_use?}
        E[并发执行工具]
        F[追加工具结果]
    end

    A --> B --> C --> D
    D -- Yes --> E --> F --> B
    D -- No --> End([End])

我也参考 LangChain 和 Vercel AI SDK 支持了多模型接入。目前主流的接口协议其实就两种——Anthropic 和 OpenAI 兼容协议,所以内置了两个 Provider,支持通过 LLM_BASE_URL 指向任意兼容网关(DeepSeek、Kimi、aihubmix 等)。

多模型支持通过 Provider 接口实现,只需实现两个方法:

interface LLMProvider {
  invoke(params: InvokeParams): Promise<AssistantMessage>
  stream(params: StreamParams): AsyncGenerator<AssistantMessage>
}

stream() 是一个 AsyncGenerator,yield 两种事件:partial(流式增量)和 message(完整消息),消费方按需选择 mode: "token"mode: "message"

二、内置核心工具:让 Agent 变成 Coding Agent

一个普通的 ReAct Agent 只是个会用工具的聊天机器人。让它成为真正的 Coding Agent,需要一套完整的文件系统和 Shell 操作工具。code-artisan 内置了以下 9 个工具,全部通过 Sandbox 抽象层执行:

工具用途
read_file读取文件内容(支持行范围)
write_file写入文件,自动创建目录
str_replace在文件里做精确字符串替换(增量编辑)
bash执行 bash 命令,支持 run_in_background 模式(见踩坑章节)
ls列出目录
glob按 glob pattern 找文件
grep按正则在文件内容里搜索
web_search联网搜索
web_fetch抓取网页正文(搜索之后读完整内容)

工具通过 defineTool() 注册,每个工具有 Zod schema 定义的入参和 invoke(input, ctx) 执行函数。ctx 里注入了 sandbox(执行环境)和 abortSignal(取消信号),工具本身不关心底层是本地还是云沙箱。

三、中间件设计:横切关注点的解耦

想要将 Agent 投入生产,往往需要很多额外的逻辑。如果都堆在主循环里,会让 Agent SDK 臃肿不可维护。所以参考 LangChain,我设计了 8 个生命周期钩子形成完整的中间件系统:

钩子触发时机典型用途
beforeAgentRun整个 Agent 任务开始前初始化资源、注入 system prompt 片段
afterAgentRun整个 Agent 任务结束后持久化结果、清理资源
beforeAgentStep每一轮 LLM 调用前上下文压缩判断
afterAgentStep每一轮 LLM 调用后死循环检测、日志埋点
beforeModel调用模型前最后一刻修改 messages、prompt caching
afterModel模型返回后最早改写 assistant 消息
beforeToolUse每个工具调用前权限检查、审计
afterToolUse每个工具调用后文件追踪、增量同步

code-artisan 内置了 5 个开源社区已有共识的中间件:

  • MicroCompact:上下文里堆积了大量 tool_result 时,把最早的 N 条替换为占位符,从而降低 token 消耗
  • AutoCompact:整个对话上下文超过 token 阈值时,调用廉价模型总结历史,插入一条总结 user message,之前内容不再传给模型
  • LoopDetection:用 MD5 对 (toolName + input) 哈希维护一个滑动窗口,检测重复调用——Agent 卡在同一个文件上反复操作时触发,设置 shouldStop 优雅退出
  • Skills:按需加载 SKILL.md 元数据到 system prompt(详见模块五)
  • Todo:让 Agent 写出可视化任务列表,支撑长任务

每个中间件都是一个普通函数,返回 Partial<AgentContext> 来修改上下文,互相独立,可组合。

四、沙箱设计:安全隔离的执行环境

让 Agent 在用户的机器上直接执行任意 Shell 命令是不现实的——安全隔离是 Web Coding Agent 的基本前提。每个对话都需要一个独立的执行环境,文件操作和命令执行必须限制在沙箱边界内。

code-artisan 把沙箱抽象成一个 6 方法的接口:

interface Sandbox {
  exec(command: string, options?: ExecOptions): Promise<ExecResult>
  readFile(path: string): Promise<string>
  writeFile(path: string, content: string): Promise<void>
  listDir(path: string): Promise<FileEntry[]>
  glob(pattern: string, path: string): Promise<GlobResult>
  grep(pattern: string, path: string): Promise<GrepResult>
}

Agent SDK 本身完全不关心沙箱实现——本地调试用 LocalSandbox(直接跑 Node.js),线上用 E2BSandbox,通过依赖注入在 Agent 创建时传入。

为什么选 E2B,不用 WebContainers?

WebContainers 是在浏览器里跑一个虚拟 Linux,乍一看很酷,但实际尝试下来问题很多:文件系统 API 不是标准 POSIX,很多 npm 包和 Shell 命令跑不起来;网络访问受到浏览器安全策略限制;E2B 是云端真实 Linux 容器,启动快、API 完整、可以挂自定义模板(code-artisan 用了一个预装 Bun + Skills 的自定义模板)。这部分细节后面会单开一篇文章讲。

五、Skills 系统:可扩展的任务加速器

Agent 是通用的,但实际任务往往集中在少数场景。与其每次从零推理,不如把这些场景的"最佳实践"固化下来。

在 code-artisan 里,Skills 就是一组放在沙箱中的 SKILL.md 文件,带有 frontmatter 元数据 + 可执行指引。

是按需加载机制,而不是一股脑塞进 prompt。

createSkillsMiddleware 会在 beforeAgentRun 阶段扫描目录,抽取每个 Skill 的最小信息(name / description / path),注入到 system prompt:

<skill_system>
<skills>[ { name, description, path } ... ]</skills>
</skill_system>

Agent 只拿到"技能目录",而不是完整内容。当它判断当前任务命中某个 Skill 时,才会通过 read_file 拉取对应的 SKILL.md,再按里面的步骤执行。

这样做有两个直接好处:

  • 控制上下文体积:避免 prompt 被大量低相关内容污染
  • 保持灵活性:Skill 可以随时扩展,不影响主流程

code-artisan 内置了一个全栈开发 Skill(hono-fullstack),覆盖"从 0 搭一个 Web 项目"的常见路径。Skills 存在于沙箱镜像(/opt/skills/)中,构建镜像时一并打包进去,运行时直接可用,没有额外加载成本。

六、MCP 支持:工具能力无限扩展

MCP(Model Context Protocol) 是 Anthropic 发布的开放协议,定义了 LLM 如何与外部工具交互的标准接口。越来越多的服务开始提供 MCP Server,意味着 Agent 的工具能力可以持续扩展,而不需要修改 Agent 本身的代码。

code-artisan 的实现思路:

  1. MCP 工具市场:前端展示可用 MCP Server 目录(mcp-registry.json),用户一键安装,配置存到用户 KV settings
  2. Agent 启动时加载McpToolSet 读取用户已安装的 MCP Server 配置,通过 StdioClientTransport 建立连接,listTools() 获取工具列表
  3. 工具包装:每个 MCP 工具的 JSON Schema 通过 zod-from-json-schema 转成 Zod schema,包装成标准的 FunctionTool,与内置工具无缝混用
  4. Agent 自动感知:下一轮对话,新安装的工具就出现在 Agent 的工具列表里

期间踩过几个比较印象深刻的坑

1. 常驻进程(dev server)与 bash 超时冲突——Agent 无法感知服务是否启动

最初 bash 工具只有一条路:sandbox.exec(command) 同步等待命令退出。dev server 这类常驻进程永远不会退出,结果就是 Agent 一直卡在等 bash 返回,直到超时报错——但 timeout 之后 Agent 以为命令失败了,实际上服务可能已经成功启动。

更坏的情况:Agent 会尝试重启服务,多个 dev server 占用同一端口,然后全部报错,Agent 陷入死循环。

解决方案是参考 Claude Code 的设计,把 bash 工具拆成三个:

  • bash:加 run_in_background 参数。false 走同步 sandbox.exectrue 开 PTY 会话,立即返回 sessionId
  • bash_output:轮询指定会话的最新输出(带 offset 游标),Agent 启动 dev server 后主动调用它来确认服务是否起来
  • kill_shell:终止指定会话

system prompt 里加一条行为规则:run_in_background=true 后必须等 2 秒再调 bash_output 确认启动状态,如果 exitCode 非零,读 tail 自行诊断修复。Agent 从此可以「看着终端自己 debug」。

2. 终端输出没颜色、不能交互——从 SSE 桥接到 PTY + WebSocket 的重写

第一版终端实现用的是 spawn + SSE:命令输出通过 SSE 事件推给前端,前端把字符塞进 xterm.js。这个方案跑通了基本功能,但有三个致命问题:

  • 颜色全失spawn 没有分配 TTY,进程检测到 !process.stdout.isTTY 后自动关闭 ANSI 输出。npm/vite/tsc 的彩色输出全变成灰白裸文本。前端只能手动在命令前后加颜色前缀,伪造一个「看起来像终端」的效果
  • 无法交互:SSE 是单向通道,用户输入无法传回沙箱。Ctrl+C、Tab 补全、方向键历史全部失效
  • Agent 感知不到进程状态:stdout/stderr 只推给了前端,没有进入 Agent 的 tool result,Agent 不知道服务有没有跑起来

最终完整重写:E2BSandbox 上增加私有 PTY API(sdk.pty.create),后端实现 ShellSessionManager 统一管理所有 PTY 会话(Agent 会话和用户会话共用一套),前端 terminal 通过独立 WebSocket 双向连接,原始 ANSI 字节直接透传给 xterm.js。

3. 性能问题:串行 I/O + 海外数据库 region,首屏 TTFB 高达 5 秒

测试过程中,我总感觉流式输出超级快(因为用的是国内模型),但每次等待第一条流式消息都要等很久,让我一度怀疑流式没有生效,更像是缓存后一次性 flush。埋点之后发现问题核心是 DB:

  • DB 托管在 Supabase,默认部署在海外,单次 round-trip 700ms+
  • 首屏链路是 5 步串行:insertMessagebuildAgentMessagesacquireSandboxcheckQuota → 调用 LLM
  • 五步串行下来 TTFB 轻松 4~5 秒

优化方向:

  • 并行化insertMessagebuildAgentMessagesacquireSandbox 三件独立的事改成 Promise.all 并发执行
  • fileTracker 增量扫描:初始化时从 DB 快照预置文件清单,后续用 find -newer {mtime} 增量 diff,跳过全量扫描
  • quota 本地缓存checkQuota 加 LRU 内存缓存,DB 写入改 fire-and-forget

最终 TTFB 从 ~5s 降至 ~1s,效果非常明显。

4. 文件持久化 & 前端同步:只追踪 write_file 工具是不够的

最初的文件追踪逻辑只监听 write_file 工具的调用:每次 Agent 写文件,就把路径和内容存进 DB。测试过程频繁出现「Agent 跑完了,但有些文件没保存下来」的问题。

原因是 Agent 在很多情况下不用 write_file——它会直接用 bash 执行 echo "..." > file.tssed -imv、甚至 npm create 这类命令,生成大量文件,而这些完全绕过了工具层的监听。

解决方案是自己实现一个 mtime-based 文件 monitor:

  • 基线:Agent run 开始时,记录所有文件的 sha256 + mtime 清单
  • 增量 diff:每次工具执行后(afterToolUse),如果是 bash 类工具,就跑 find {workspaceRoot} -newer {lastCheckTime} 找出变更文件
  • 精确追踪write_file 这类直接文件工具,走路径直接追踪,不用 mtime
  • 推送:变更文件通过 file_update SSE 事件实时推给前端,文件树即时更新;run 结束后 afterAgentRun 做最终 diff,upsert 到 DB

5. 架构拆分不合理:用 CLI 场景做验证,理清楚了正确的包边界

早期开发图方便,把很多 web 特有的东西(PTY、run_in_backgroundspawnProcessHandle)直接塞进了 @code-artisan/agentSandbox 接口里。表面上看「功能能跑」,但架构上已经混乱了:一个「环境无关」的 Agent SDK 开始感知 PTY 和后台进程,LocalSandbox 里出现了一堆 web 才需要的实现。

真正的问题是缺少第二个消费场景来检验接口是否合理。

CLI 就是这把尺子:我后来用 Ink 写一个终端版 Agent,只用 @code-artisan/agent,不依赖任何 backend 代码——CLI 用户本来就在真实 shell 里,根本不需要 PTY、不需要 bash_output、不需要 ShellSessionManager。用这个约束反推,Agent SDK 里的所有 PTY 相关代码都没有存在的理由,全部下沉到 backend 的 E2BSandbox 里,通过 createAgent({ tools: [...] }) 以注入方式传进来。

CLI 目前代码量非常轻,基础结构已经在,这也是一个很好的参与点——如果你对用 Ink 实现一个命令行 AI Agent 感兴趣,欢迎一起来做。

现在的包边界规则很简单:CLI 和 Web 都要用的,才放进 @code-artisan/agent;只有 Web 需要的,放进 @code-artisan/backend

系列文章预告

这篇是开篇,后续我会按 6 个核心模块各开一篇深度展开:

  1. ReAct Agent Loop & 多模型支持——从 0 手写一个生产级 Agent 循环,以及 Provider 抽象怎么设计
  2. 内置工具链——工具注册机制、Zod schema 与工具调用、如何给 Agent 扩展自定义工具,如何在运行过程中主动提醒 Agent 调用工具
  3. 中间件系统——8 个钩子的设计哲学,MicroCompact / AutoCompact / LoopDetection 的实现细节
  4. 沙箱设计——为什么需要沙箱、E2B 接入全攻略、自定义沙箱模板、PTY API 实现
  5. Skills 系统——如何实现按需加载机制、如何让 Agent 真正「用好」Skills
  6. MCP 集成——MCP 协议原理、工具市场实现、如何把 MCP 工具无缝接入 Agent

最后

代码在 GitHub:github.com/lhz960904/c…

这是一个人在业余时间做的学习项目,代码不完美,欢迎提 PR 和建议。如果这个项目对你有帮助,欢迎点个 star ⭐。

也欢迎大家加我微信直接开聊,不限于该项目。

image.png