引言
lume-crm 是一个完全用 Lume 写的 CRM 后台:客户、商机、跟进管理,加一个可以直接对话的 LLM Agent。
这个项目真正想验证的不是"Lume 能不能做 CRUD",而是另一件事:Web 请求和 Agent 操作,能不能直接进入同一套业务逻辑?
在 lume-crm 里,HTTP 路由和 Agent Tool 都直接调用领域层函数。客户、商机、跟进的校验和 SQL 只有一份,Agent 不是额外外挂的一层,而是业务函数的另一种入口。
数据库、REST API、SSR 分享页、Agent 工具,全由三份 .lume 文件撑起,最后编译成一个 C11 单二进制。前端仍然使用标准 React,但运行时不需要 Node 服务。
这篇不重复讲 Lume 是什么、怎么安装、内部怎么工作(那篇在 erishen.cn/lume),而是直接看一个完整应用是怎么组织起来的。下面是这个项目的实际写法,全部片段从仓库直接摘的。
这个 CRM 能做什么(三十秒版)
仪表盘统计 + 客户列表/详情 + 商机管道(初步接洽 → 方案 → 谈判 → 成交/丢单)+ 跟进记录(电话/会议/邮件/拜访)+ Agent 聊天 + 客户 SSR 分享页 + 教学页。功能不是重点,重点是这堆东西的服务端只有三份 .lume 文件——路由、SQL、工具、页面渲染全在里面,没有第二个服务进程。
先看整体结构
lume-crm 的核心结构其实很简单:
React SPA ──► HTTP 路由 ──┐
│
LLM ──────► Agent Tools ──┼──► Domain Functions ──► SQLite
│
SSR 分享页 ───────────────┘
这里最重要的是中间的 Domain Functions。HTTP 路由和 Agent Tool 都调用同一套业务函数——POST /api/customers 和 crm_add_customer Tool 最终进入的是同一个 add_customer()。不是给 CRM 再外挂一套 Agent 逻辑,而是让同一个业务函数同时成为 Web API 和 Agent 的入口。后面所有代码都是围绕这一点展开的。
入口:一个文件声明整站
crm.lume 是唯一的入口,开头就是整站的声明:
let crm_bind = env("LUME_BIND");
if (crm_bind == null) { crm_bind = "127.0.0.1"; }
server {
port = 8089;
workers = 2;
bind = crm_bind;
docroot = "./www";
spa = true; // SPA history 路由:静态 404 回退 index.html
htpasswd = env("HTPASSWD_FILE");
};
几个值得注意的写法:
spa = true一行解决 SPA history 路由——单页应用里刷新/customers/3这类深链会 404,这行让静态 404 自动回退到index.html,API 404 保持原样。不用配 nginx fallback。bind从环境变量读——本地默认绑127.0.0.1,容器里传LUME_BIND=0.0.0.0才能被端口映射命中。部署形态用 env 表达,而不是改脚本。htpasswd直接挂文件路径——Basic Auth 是语言级的,一行开、一行关。
路由:HTTP 只负责协议
读路径几乎就是一行:
get "/api/stats", (req) => {
return crmdb.stats_payload(); // 返回 map,框架自动序列化成 JSON
};
get "/api/customers", (req) => {
let q = get(req.query_params, "q", "");
let sort = get(req.query_params, "sort", "");
let list = crmdb.customer_list(q, sort);
return { count: len(list), customers: list };
};
handler 返回一个 map,框架自动按 200 application/json 序列化——没有 stringify(),没有手写 status/type。
写路径是这个项目沉淀出来的一套约定,以新建客户为例:
post "/api/customers", (req) => {
let gate = write_gate();
if (gate != null) { return gate; }
let b = body_of(req);
if (b.err != null) {
return { status: 400, body: { err: b.err } };
}
return resp(crmdb.add_customer(
str(b.ok, "name", ""),
str(b.ok, "company", ""),
str(b.ok, "email", ""),
str(b.ok, "phone", "")), true);
};
三个小工具函数承担了全部脏活,每个路由都是这个形状:
write_gate()——只读演示模式下整条写路径直接返回 403,本地开发时为 null 放行。读路由不经过它。body_of(req)——body 解析。req.body为空返回{ ok: null, err: "缺少 body" },JSON 非法返回{ ok: null, err: ... }。固定返回{ ok, err }两键,路由侧只用b.err/b.ok访问,不会崩。resp(res, created)——把领域层结果包成 HTTP 响应:失败带 status(默认 500)、新建返回 201。
约定是:路由只管 body 解析和状态码,校验、锁、SQL 全部下沉到 src/db.lume 的助手函数。路由薄,业务厚。
一句话总结这套设计:Lume CRM 的路由层故意保持很薄——HTTP 负责协议,领域函数负责业务,Agent Tool 负责把领域能力暴露给模型。HTTP、Agent 和 SSR 各自负责自己的访问或输出方式,中间的业务逻辑只有一份。
顺带认识一下上面出现的 str(b.ok, "name", "") 三参形式:str/int/float(m, k, default) 是 Lume 的安全读取写法——取值、转类型、缺键兜底一步到位(等价旧的 str(get(m, k, default)))。后面「读取与容错的几个写法习惯」一节还会细讲。
领域层:业务逻辑只有一份
CRM 的业务逻辑集中在 src/db.lume。SQL 直接写在 DSL 里,不需要再引入一套 ORM。更重要的是,这里的函数同时服务于 HTTP 和 Agent——比如 add_customer():
HTTP POST /api/customers ──┐
├──► add_customer() ──► SQLite
crm_add_customer Tool ─────┘
因此客户创建的校验、SQL 和错误处理只有一份。以后如果增加一个 CLI、定时任务或者另一个 Agent,也不需要重新实现一套 CRM 逻辑,只需要增加一个新的入口。
SQL 就是 DSL 的一部分,不用拼 ORM:
export func stats_payload() {
let by_stage = {};
for (s in q("SELECT stage, COUNT(*) AS n, COALESCE(SUM(amount), 0.0) AS amt FROM deals GROUP BY stage", null)) {
put(by_stage, s.stage, int(s.n));
put(by_stage, s.stage + ".amt", float(s.amt));
}
let open = q("SELECT COALESCE(SUM(amount), 0.0) AS t FROM deals WHERE stage NOT IN (" + in_clause(closed_stages) + ")", null);
...
}
q("...") 直接执行 SQLite,参数化查询传数组(q("SELECT ... WHERE customer = ?", [cid])),聚合统计全部在 SQL 里算完再包装。阶段定义(closed_stages)只有一份,SQL 统计和推进商机时的自动留痕共用它,改一处全同步。
Agent 工具:业务函数声明即工具
在 Lume 里,Tool 不是另一套 API,而是业务函数的 Agent 入口。领域层已有的助手函数,加一个 tool 声明就变成 Agent 可调的工具:
tool "crm_add_customer", "新建客户。name 必填;company/email/phone 可空(空串占位)。", { name: string, company: string, email: string, phone: string }, (arg) => {
return add_customer(arg.name, arg.company, arg.email, arg.phone);
};
tool "crm_add_activity", "给客户记一条跟进(电话/会议/邮件/拜访…),note 必填;kind 空串默认 记录。", { customer_id: int, kind: string, note: string }, (arg) => {
return add_activity(arg.customer_id, arg.kind, arg.note);
};
要点:
- 参数写裸类型关键字(
{ name: string, customer_id: int, amount: float }),给模型看的 JSON schema 由编译层生成,不手写。 - 给 LLM 的描述是参数的一部分——「name 必填」「kind 空串默认 记录」这些约束直接写进声明,模型按它生成参数。
- 工具和 HTTP 路由调的是同一个助手函数——聊天里说「记一条电话跟进」和 curl 打
POST /api/activities,走的是同一份校验和 SQL。不会出现"页面能建、聊天不能建"的两套逻辑。
10 个 crm_* 工具(增删改查客户/商机/跟进)就是这么声明出来的,一行一个。
这意味着什么?传统应用通常先设计 REST API,再为 Agent / MCP 单独设计工具接口,两边最终还需要保持业务规则一致。这里的做法则相反:先把业务能力沉淀成领域函数,再让 HTTP 和 Agent Tool 成为它们不同的入口。这也是 lume-crm 里"AI 原生"的部分:Agent 不需要一套平行的业务实现。
SSR 分享页:同一个领域数据的另一种输出
客户分享页 src/share.lume 是一个纯函数组件,用 html() 标量槽做转义:
func share_page(c) {
return html(
"<h1>{0}</h1>"
+ "<div class='contact'>…</div>"
+ "<section><h2>商机 Pipeline</h2>{5}{6}</section>"
+ "<section><h2>跟进记录</h2><ul>{7}{8}</ul></section>",
str(c.name), str(c, "company", ""),
str(c, "email", ""), str(c, "phone", ""),
deal_rows, deal_empty, acts, act_empty);
}
SSR 不是再做一套业务逻辑,而是把领域数据转换成适合分享的 HTML。客户字段全部走 html() 槽自动转义,存进库里的恶意内容渲染出来也是纯文本。整个页面零 JavaScript,手机打开正常,直接外发链接当"客户档案"用。一个 SPA 应用里要服务端渲染的部分,不需要为此再养一个 Node 服务。
前端:标准 React,产物是静态文件
前端是标准 React SPA(仪表盘/客户/聊天/教学四个视图),esbuild 打成一个 app.js 静态产物,构建完 COPY 进镜像。运行时没有 Node 进程——docroot = "./www" 就是全部。
读取与容错的几个写法习惯
Lume 的成员访问是严格的——访问稀疏 map 的缺键会直接 500。所以这个项目跨 map 取值一律走安全读取,按场景分三种:
- 要转换就一步到位:
str/int/float(m, k, default)三参形式,取值、转类型、缺键兜底一次完成(等价旧的str(get(m, k, default)),两种写法都合法); - 纯取值:
get(m, k, default); - 可能抛错的调用:
try(() => ...),固定返回{ ok, err }两键。
这套习惯是所有路由形状的来源:body 解析固定两键、助手返回稀疏 map、路由侧三参 cast-and-get / get() 兜底。写熟了之后,新加一个资源基本就是复制路由模板并修改对应业务函数。
为什么这样组织应用
这套组织的价值主要有三点:
- Web 和 Agent 共用领域函数,避免两套业务逻辑——HTTP 路由和 Agent Tool 调用同一批领域函数,SSR 分享页直接使用领域数据,业务校验和 SQL 只有一份,从结构上避免 Web 和 Agent 各维护一套业务逻辑。
- 运行时简单——服务端只有三份 .lume 文件,编译成一个静态二进制;React 编译成静态前端资源,运行时没有 Node 进程,
docroot = "./www"就是全部。 - 编译期拦错:
lume --check离线就能过一遍类型检查(这个项目make check就干这个),路由/工具/字段的错在提交前就拦掉,不用等到线上崩。
这些选择的自然结果:3.8MB 的静态二进制、约 18MB 的 Docker 镜像。对于小型 CRM 或内部工具,这种部署形态可以把运行时依赖压缩到很少。项目里还带一个 /examples 教学页,包含 5 段从 server{} 到 tool 声明的 DSL 片段,可以直接作为新应用的起点。
安全护栏
这些机制不是部署时再补的外围配置,而是直接写在应用 DSL 和运行时里:
- 写操作:
write_gate()统一控制——只读演示模式整条写路径 403,本地开发放行。 - SQL:
q()只允许参数化查询,业务代码没有一处把用户输入拼进 SQL 字符串。 - XSS:React 默认转义 + SSR 分享页
html()标量槽,双层防线。 - Basic Auth:
server{}挂htpasswd文件路径即开,语言级、fail-closed——配错凭据一律 401,不会退化成"放行所有"。 - 网络:默认绑
127.0.0.1,容器部署才显式传LUME_BIND=0.0.0.0开端口映射。 - Agent Chat:底层 agent-httpd 提供同源 + POST-only 检查——想借聊天接口从别的站点发请求,在网关层就被拦。
怎么试
- 线上 demo:https://lume-crm.erishen.cn —— 公网只读实例,打开就能玩。
- 本地跑:装好 lume 后
lume crm.lume,浏览器开 http://127.0.0.1:8089。 - Docker:
docker run -d -p 8089:8089 lume-crm,SQLite 在/app/.data/,挂卷持久化;接真实 LLM 传LLM_API_URL/LLM_API_KEY,不配则用内置离线引擎,聊天照常可用。
关于 Lume 与它的底层 agent-httpd
- Lume 本身(是什么、内部机制、工具链、设计取舍,基础介绍见引言链接):仓库 github.com/erishen/lume。
- 底层 agent-httpd:lume 的聊天 SSE、Agent Tool 调度和工具 schema 生成,都来自独立开源项目 agent-httpd,以
libagenthttpd.a静态链接进 Lume。聊天接口的同源 + POST-only 护栏也由它提供。想深入 agent 网关本身,直接看它。 - 样板仓库:erishen/lume-crm。想照着写下一个应用,直接抄这个仓库的结构。
源码导航
crm.lume— 入口:server{}+ 全部 HTTP 路由 + 写路由约定(write_gate/body_of/resp)。src/db.lume— 领域层:SQLite 助手 + SQL 内联 + 10 个crm_*工具声明。src/share.lume— SSR 分享页的纯 C 渲染组件。src/crm/*.tsx— React SPA 四个视图。
FAQ
服务端真的要 Node 或 Python 吗?
不需要。整个后端是一个 C11 单二进制 lume + 三份 .lume 脚本;前端 React 是构建时的静态产物,运行时没有 Node 进程。
Docker 镜像多大?
约 18MB(alpine 基底 + lume 静态二进制,SQLite 内建,零运行时依赖)。
Agent 工具和 HTTP 接口是两套逻辑吗?
不是。工具声明和路由调的是同一个助手函数,校验和 SQL 只有一份。
怎么写下一个应用?
抄这个仓库的结构:server{} 声明 → 路由薄层 → src/ 领域助手 → tool 声明挂 Agent;make check 过一遍类型检查。
发表回复