agent-httpd:让 AI 一路写到 C

作者:

🇬🇧 English

一句话:agent-httpd 是一次底层编程实验——我想看看 AI 到底能把代码写到哪一层。它从 TypeScript / Python 出发,一路往下碰到了 C,又碰了碰汇编与 SIMD;而这个实验的载体,是一个纯 C 实现的 HTTP/1.1 服务器。

引子:AI 能写 C 吗?

今天让 AI 写 React、Node.js、Python,已经不稀奇了。我更想知道另一头的事:

它能不能写 C?

所以我没有停在 demo 上,而是直接拿一个真实项目来试:写一个 HTTP 服务器。这个项目后来长成了 agent-httpd。

写完 C 之后,我又追问了一句:C 就是底了吗? 于是让它继续往下,去碰汇编和 SIMD。再往下,还有字节码。

这篇文章要记录的不是"AI 多强",而是——把它一路往下推之后,它在哪一层开始变吃力

先给一个正式定义:

agent-httpd 是一个纯 C 实现的 HTTP/1.1 服务器:单进程事件循环(kqueue / epoll)+ prefork worker 池把快/慢路径拆开,附带 CGI / FastCGI、SSE、React SSR,以及一个不依赖 Python / JS 运行时的原生 C LLM agent 栈。

先把我的位置说清楚:我有 C、socket、CGI 的经验,也长期写 Node.js,但 preforkepollSCM_RIGHTS 这些并没有系统研究过——它们是在这个项目里边做边弄明白的。所以这不是一份底层专家的经验总结,而是一次 AI + 工程实践的探索:我出需求、盯方向、验证结果,代码大部分由 AI 写。

顺手把边界也讲清楚:这里的"纯 C"指服务器核心——TLS 交给 fork 出来的 curl(1),JSON 用手写解析,MCP 美化借 jq(1)。不引 OpenSSL、不引 JSON 库,是刻意的项目约定。

实验:把 AI 从应用层推向底层

第一阶段:让 AI 写 C

构建一个 C 的 HTTP 服务器,要碰的东西并不少:

  • socket 编程与 accept 循环;
  • epoll(Linux)/ kqueue(macOS)事件循环;
  • prefork 进程模型,以及进程间的 fd 传递(SCM_RIGHTS);
  • CGI:fork + pipe + execl
  • 慢路径的隔离与并发控制。

这些 AI 都能生成。但真正的工作量在另一头:

AI 负责生成大部分代码;我负责提出需求、把它跑起来、看错误、把问题范围缩小,再验证结果。

这和我平时写业务代码的方式其实没差多少,只是这次的"语言"换成了 C——反馈更硬。段错误、fd 泄漏、逻辑竞态,都得靠实际运行才能暴露出来。

第二阶段:再往下——汇编与 SIMD

C 写顺之后,问题自然变成:能不能再往下?

我在项目里留了一组实验(asm-demo/):手写的 .c 源码与一份 Makefile 已入库,但 clang -S 生成的 .s 汇编转储和编译出的 Mach-O 二进制只留本地。拿服务器里真实的性能敏感原语来做对照:HTTP 头部的 CRLF 扫描、缓冲区里的单字节查找(memchr 的形状)、以及 html_escape。每个原语写几种实现,用 clang -S 编译成汇编,再实测。

以单字节查找为例,三种写法:标量逐字节循环、直接调 libc memchr、手写可移植 SIMD(aarch64 NEON / x86_64 SSE2)。

/* 标量:与 http.c 里的写法一致 */
for (size_t i = 0; i < n; i++)
    if (buf[i] == '\n') return (long)i;

/* 手写 NEON:一次比 16 字节,命中后再标量定位 */
uint8x16_t vn = vdupq_n_u8('\n');
for (; i + 16 <= n; i += 16) {
    uint8x16_t eq = vceqq_u8(vld1q_u8((const uint8_t *)buf + i), vn);
    if (vmaxvq_u8(eq)) { /* 块内命中,标量精查下标 */ }
}

编译成汇编之后,能直接看到编译器为 scalar 生成的循环、和手写 SIMD 对应的 cmeq / umaxvq / ldr q 指令。观察很直接:

  • AI 写这类 SIMD intrinsic,编译、跑、能根据报错继续改;
  • 但代码量和维护成本明显上升——一段几行的标量循环,展开成带边界处理和对齐假设的版本;
  • 而且它不是免费的午餐:手写 SIMD 能稳赢标量,却往往赢不过 libc 里那些被调了十几年的实现。

最值得记录的一次是 html_escape。我原本以为瓶颈在"找特殊字符",所以 SIMD 应该有用。数据把它否了:

真正的瓶颈是循环里的 snprintf,不是字节扫描。

SIMD 优化的是后者,于是几乎没收益;反倒是把 snprintf 换成 memcpy 直写替换串的纯标量版本成了赢家。换句话说,在没有定位到真瓶颈之前,"上 SIMD / 汇编"很可能是在优化错的地方。

还有一个更朴素的结论:agent-httpd 的请求体封顶 64KB、整体又是 I/O 主导,这些原语在真实负载里的绝对收益微乎其微。所以这一层的价值,更多是"知道边界在哪",而不是"拿来提速"。

再往下一层:字节码——这次我停在这里

那能不能让 AI 直接生成字节码?

LLM 的输出本质上还是 token 序列。它可以生成表示字节码的文本或十六进制,但这和"直接产出一段经过约束、验证、可执行的 VM bytecode"不是一回事——中间缺的是约束和验证,而不是表达能力。

理论上还能继续往下,但在这条「以 LLM 生成代码为主」的工作流里,字节码不像 C / 汇编那么自然:C 和汇编至少有编译器和 CPU 替你验证,字节码这一层的约束得自己造。所以这次我停在这里。 C 目前仍是个很舒服的中间层:足够接近底层,又没有汇编那么繁琐。

agent-httpd 是怎么工作的

先给一张全貌图:

                    agent-httpd
                         │
              ┌──────────┴──────────┐
              │                     │
           Master                 Worker
              │                     │
        epoll / kqueue          CGI / SSE
              │                     │
        Fast / Slow Path         Agent
                                    │
                         ┌──────────┼──────────┐
                         │          │          │
                        LLM        MCP       Skills

下面拆开说。

HTTP / 进程:master 接客,慢活外派

这是整个服务器的引擎。核心是两件事:epoll(Linux)/ kqueue(macOS)recv(MSG_PEEK)

每个新连接进来,master 用 recv(MSG_PEEK) 预览请求头——在不消费 socket 数据的情况下完成请求分类:

recv(fd, buf, sizeof(buf), MSG_PEEK)

基于这个预览,请求分流到两条路径:

  • 快路径:静态文件、health、304 / 301 / 404、无 body 的 OPTIONS——由 master 直接处理,不 fork。
  • 慢路径:CGI、chat SSE、上游代理——通过 SCM_RIGHTS 把连接 fd 交给 prefork worker 池,由 worker 接管。
sendmsg(fd, &msghdr_with_SCM_RIGHTS, 0);  // 把 fd 交给 worker

这里有两点值得说明:

  • master 会尽量把慢路径和可能阻塞的操作隔离开,慢活统一外派给 worker——目的是别让这些慢活影响 master 的连接分发,让快路径在压力下的行为更可预期。
  • fd 通过 SCM_RIGHTS 传过去时,请求头字节还在内核缓冲里,worker 可以像自己 accept 到的一样直接重读,不需要主循环到 worker 的字节重放

默认 8 个 worker(-w 可调;设为 0 回退到传统的 fork-per-connection 模型)。

顺带抽出来的一层:libagenthttpd

写到这里我意识到,这些能力不该只属于这一个二进制。于是把 HTTP 分发、CGI / FastCGI、SSE、Agent 工具这些部分抽成了静态库 bin/libagenthttpd.amake lib):链接它、注册自己的路由和 Agent 工具,就能在自己的进程里起一个「自带 Agent 的 HTTP 服务器」;CLI 二进制只是同一套 API 的薄前端。

#include "agenthttpd.h"

agenthttpd_route("GET", "/api/status", my_status_handler);
agenthttpd_tool_exec("wordcount", "...", params_json, "python3 tools/wordcount.py");
agenthttpd_run(&(agenthttpd_config){ .port = 18101, .workers = 4 });

公开 API 刻意做得很小(4 个函数 + 1 个结构体,见 src/agenthttpd.h)。有个约束值得记一笔:注册必须在 agenthttpd_run() 之前——工具表和路由是 fork 前初始化的,worker 继承的是冻结副本,run 之后再注册既不可见、又有竞态。

成熟度也说清楚:这是把既有能力抽成一层可复用的 C 基础设施,不是成熟 SDK,也刻意不做成通用 C Web 框架(那条路上已经有 libmicrohttpd / mongoose / drogon)。make example-run 会把 examples/embedded 起在 :18101,完整取舍记在 docs/FRAMEWORK.md

动态执行:一个二进制跑 7 种语言的 CGI

agent-httpd 同时支持 bash / Python / Go / Rust / Java / PHP / Ruby 七种语言 CGI,核心是经典 fork + pipe + execlsrc/cgi/cgi.c):fork 出隔离子进程,用管道传请求体与 CGI 环境变量,execl 把子进程换成对应的解释器或可执行文件。

标准 CGI 环境变量按 RFC 3875 就位:REQUEST_METHODQUERY_STRINGCONTENT_LENGTHGATEWAY_INTERFACESERVER_SOFTWAREREMOTE_ADDR

每种语言一个独立进程,互不干扰。不同语言的启动成本存在明显差异(解释器缓存、编译产物、运行时预热各不相同);对长连接场景,可以切到 FastCGI 模式(同时作 client 和 server)复用进程,避免每次请求重复启动。

AI:跑在 C 里的 LLM agent

agent-httpd 内嵌一个纯 C 实现的 LLM chat agent,端点是 /react/api/chat。不依赖 PyTorch、LangChain,也没有独立的 Node 服务——一个 C 二进制就把 ReAct 循环和 SSE 流式响应都装了进去(src/agent/llm.c)。

最务实的一招是 TLS 交给 curl:fork 一个 curl 子进程处理握手和 HTTP,C 代码本身不实现 TLS。

fork(); execl("curl", "curl", "--data", ..., NULL);  // curl 负责 TLS + HTTP

Agent 核心是 ReAct 循环:模型生成思考 → 调用工具 → 拿到结果 → 回传模型 → 再生成。工具调用支持 MCP(Model Context Protocol)与内置 skills,整条循环在 C 里跑完。没有 API key 也没关系,内置 demo engine 无需任何密钥即可体验流式输出。

前端:React SSR 管道

agent-httpd 内嵌一条完整的 React SSR 构建管道,由 scripts/build-ssr.sh 驱动:TypeScript + Tailwind → esbuild 打包 → 预编译 SSR bundle。渲染核心在 cgi-bin/react-ssr/server/render.tsx,CGI 模式与常驻 backend 模式共用同一套渲染逻辑,输出一致。

-v 开发模式会把 Vite dev server 的 proxy 与 HMR WebSocket 隧道到同一端口,开发体验接近纯前端项目。

缓存策略上有个关键设计:静态响应带 Cache-Control: no-cache,CGI / SSR 文档带 no-store;bundle URL 带 hash(/js/react-ssr.js?v=<hash>),内容一变 hash 就变,强制浏览器重新拉取——不去靠缓存指令"说服"浏览器刷新。

性能:设计目标与实测,而不是口号

这里我不想喊口号。

先说设计目标:把慢请求从主事件循环里隔离出去,让快路径的行为在压力下依然可预期。这是一条结构上的取舍,不是"永不阻塞"的保证——没有严格的证明,我不想这么写。

再说实测。项目自带一个压测脚本 scripts/bench.py,对比两种客户端策略(keep-alive、per-conn),输出 req/s 与 p50 / p90 / p99。在这个脚本下,默认的"master + worker 池"相比旧的 fork-per-connection,per-conn 吞吐大约有数倍的差距。

但有两点要讲清楚:其一,C 本身的速度没什么好争的,这里的看点从来不是"C 快",而是调度结构——把慢活挪出去,让快路径干净;其二,单机脚本的数字只说明结构有效,不足以当成产品级性能结论。

更完整的 wrk / hey / ab 压测、多 worker 档位、内存占用等,我还没做。等有数据之后再补。

几处容易被误读的细节

写这类项目,措辞要经得起技术读者推敲。有几个说法我特意改过:

  • MSG_PEEKrecv(MSG_PEEK) 的作用是查看 socket 缓冲区里的数据,但不消费这些数据——请求分类就建立在这份预览上。
  • "永不阻塞":改成"尽量把慢路径和可能阻塞的操作隔离开",避免它们影响 master 的连接分发。
  • "高性能":没有 benchmark 支撑时,只说"面向低开销的事件驱动模型设计"。
  • "纯 C":指服务器核心由 C 实现;部分外部能力(TLS、MCP 美化)通过 fork 子进程与外部工具(curljq)接入。

安全:把真实攻击面拿来验证

这是个实验项目,所以我也把运行中容易被盯上的攻击面拿来验了一遍。先说清一件事:下面列的是当前实现了哪些防护机制,它不等于「已经达到生产级安全标准」——那需要独立审计、长期对抗和压测,不是一个人在一个项目里能做完的事。

  • 路径穿越:静态路径经 realpath() 校验,.. 逃不出 docroot(static.c / framework.c)。

  • 每 IP 限流src/security/ratelimit.cMAP_SHARED 匿名共享内存里的令牌桶,每 IP 经 FNV-1a 哈希进固定桶;桶内用进程共享互斥锁PTHREAD_PROCESS_SHARED)串行扣减——fork-per-connection 与 worker 池两种模式下强制同一配额,超限返回 429。

    pthread_mutex_lock(&bucket.lock);
    bucket.tokens -= 1;   /* 取到令牌才放行 */
    pthread_mutex_unlock(&bucket.lock);
    
  • Basic Auth 强哈希:只接受 crypt(3) 的 SHA-256($5$)/ SHA-512($6$)或 bcrypt;明文、DES、$1$ MD5-crypt、$apr1$ 默认拒绝(仅 AGENTHTTPD_ALLOW_WEAK_AUTH dev 开关可放行弱哈希)。

  • CSRF:会改状态的 LLM 端点(/react/api/chat/react/api/pse)强制同源校验,并要求 POST。更根本的防护是设计上让 GET / HEAD / OPTIONS 永不改状态,把"读"和"写"从根上分开。

当前限制

把边界写清楚,比把能力写满更有用:

  • 不等同于生产级 Web Server,定位就是一次实验。
  • CGI 有进程启动成本(长连接场景才建议切 FastCGI)。
  • Agent 工具的权限还需要更细的隔离。
  • TLS 依赖外部能力(fork 出的 curl),不是内建实现。
  • 尚未做完整 benchmark。
  • 还没有成熟的 sandbox。

我实际测试了什么

比起"支持 XXX"的清单,我更愿意列真跑过的东西

✓ 静态文件 / 目录列表 / ETag 304
✓ CGI(多语言)
✓ 慢路径 worker 池 + SCM_RIGHTS fd 传递
✓ SSE 流式输出
✓ React SSR
✓ LLM Chat(含 demo engine 无 key)
✓ MCP 工具调用
✓ asm-demo 底层原语实验(标量 / SIMD / libc 对照)

每一项在 README 与 docs/ARCHITECTURE.md 里都有对应的命令和说明。

结尾:一条边界

这次实验让我比较直观地看到了一条边界:

AI 写 C,已经能参与复杂的系统工程;继续往汇编 / SIMD 走也不是做不到,但代码复杂度和维护成本会迅速上升。

而在底层做优化,第一件事不是"上 SIMD",而是先用数据找到真瓶颈——html_escape 那次,瓶颈根本不在我以为的地方。

回头看我在这件事里的角色,其实很朴素:这不是一次从头到尾手写底层代码的经历,而是一次让 AI 带着我往代码栈底层走的实验。 AI 能写到哪里,真正的问题又会从哪一层开始冒出来——这个问题我还没有完整答案,但至少攒下了几个具体的坐标。

C 目前仍是一个很有意思的中间层:足够接近底层,又没有汇编那么繁琐。至于更下面的字节码,这次暂时没有继续走下去。

所以对我来说,agent-httpd 并不只是一个 HTTP 服务器。

它更像一个实验场:不断把 AI 往更底层推,看看它究竟能走多远。

为什么选 prefork 而不是多线程或单进程异步?

prefork 让 worker 以独立进程运行,把不同请求的执行状态隔离开,也减少共享内存场景下的线程级同步问题。master accept 连接后通过 SCM_RIGHTS 把 fd 传给 worker,多进程并行处理,在 C 项目里兼顾了结构与实现复杂度。

CGI 模块为什么用 fork + pipe + execl 的方式?

经典 CGI 模型——fork 出隔离子进程,用管道双向传递请求体和响应,execl 把子进程替换成对应语言解释器。每种语言一个独立进程,互不干扰;长连接场景可切 FastCGI 避免重复 fork。

速率限制如何实现跨 worker 的协调?

令牌桶放在 MAP_SHARED 匿名共享内存里,每个 IP 经 FNV-1a 哈希进固定桶;桶内用进程共享互斥锁(PTHREAD_PROCESS_SHARED)串行扣减,fork-per-connection 与 worker 池两种模式强制的是同一配额,超限返回 429。

你说的"AI 写汇编"具体指什么?

准确说不是让 AI 手写汇编,而是让它写 C 与 SIMD intrinsic,再用 clang -S 编译出汇编来对照观察。实验(asm-demo/)的结论是:手写 SIMD 能赢标量,但常常赢不过 libc 的精心调校;底层优化要先定位真瓶颈,而不是默认”上 SIMD”。

安全方面有哪些硬性策略?

密码存储只接受 crypt(3) 的 SHA-256/SHA-512 或 bcrypt,明文与 MD5 等弱哈希默认拒绝(dev 开关可放行);CSRF 防护上,状态变更的 LLM 端点强制同源 + POST,而 GET/HEAD/OPTIONS 等读方法被设计为永不修改服务器状态。

React SSR 的缓存策略为什么用 hash 而非浏览器缓存指令?

bundle 文件名携带 hash(如 /js/react-ssr.js?v=),内容一变 hash 变化就迫使浏览器重新拉取,不依赖 Cache-Control 去”说服”浏览器刷新。同时响应标记 no-cache/no-store,确保内容始终新鲜。

开发时如何调试?

使用 -v 模式,Vite dev server 的 proxy 和 HMR WebSocket 都隧道到同一端口,前后端开发体验接近纯前端项目。


项目地址github.com/erishen/agent-httpd

如果你也好奇 AI 到底能把系统代码写到什么程度,可以直接去看源码和实验过程。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

商店 Web Chat Nsbp 关于 隐私政策

@ 2026 ESN
沪ICP备2024079226号-1   沪公网安备31010502007082号