拒绝 LangChain!48小时黑客松,零框架、单进程,手搓智能体的技术细节

手搓药品管理智能体技术细节。

文档更新 · 2026-07-288 min read
大家好!我是瓜哥。前互联网技术副总裁,现在带队死磕 AI 编程。

上一篇,聊完了 med-agent 的产品设计和适老化交互。

上篇回顾:48小时黑客松:我们是如何给老年人设计 “零门槛” 用药助理的?

发出去之后,不少技术方向的朋友跑来私信问:

你们后端到底怎么搭的?用了 LangChain 还是 LangGraph?

我的回答是:都没用!

48 小时黑客松里,我们做了一个极其大胆的决定。

不引入任何 Agent 框架,也不搞微服务。一个 Python 单进程,一个手写的 While 循环,搞定了全部 Agent 逻辑。

今天我就把(小谢)负责的整个后端的技术栈、Agent 引擎实现和现场踩坑经验,掰开揉碎讲一遍。

01 | 极简技术栈:我们是怎么选型的?

① 后端选型:拒绝重型框架,只用刚好够的工具

先说结论。后端就 7 个运行时依赖:FastAPI 做 HTTP 服务,Pydantic 做数据校验,openai SDK 对接模型,sse-starlette 做事件推送,zxing-cpp + Pillow 做条码识别,python-multipart 处理文件上传。

说人话,就是:拒绝 LangChain 这类重型 Agent 框架,拒绝 SQLAlchemy 这类重型 ORM。

该手写的全手写

下面是我们和传统方案的直观对比:

数据库这块我必须单独说。

直接用 Python 标准库 sqlite3 手写了整个 repo.py 数据仓库。

每一条 SQL 清清楚楚,黑客松现场出了 Bug 一秒定位。

code
# app/db/repo.py - 零 ORM,手写原生 sqlite3 模块级连接def connect(db_path: str | None = None) -> sqlite3.Connection:    global _conn, _conn_path    path = db_path or config.DB_PATH    if _conn is not None and _conn_path == path:        return _conn    if path != ":memory:":        Path(path).parent.mkdir(parents=True, exist_ok=True)    conn = sqlite3.connect(path, check_same_thread=False)    conn.row_factory = sqlite3.Row  # Dict-like 字典映射    _conn, _conn_path = conn, path    return conn

这个选型,回头来看,绝对是黑客松赛事(限时创作)的最佳推荐。

② 薄 HTTP 层:FastAPI 只管传话,不管干活

为什么选单进程?

因为多进程之间同步虚拟时钟和内存状态,在 48 小时里就是 自找麻烦

项目的 app/api/ 目录被压缩得极薄。

简单说就是:

  • POST /chat 接收消息
  • POST /upload 接收图片和录音
  • GET /events 推送 SSE 事件流
  • POST /confirm 处理用户确认

HTTP 层只做转发,业务逻辑全在底层的 tools/ 和 core/ 里。

③ 局域网 HTTPS:差点让项目夭折了

前端用 Vite + React 19 + antd-mobile + TypeScript 搭建。

但局域网测试时遇到一个大坑:手机浏览器调用录音接口,强制要求 HTTPS。

基于MVP快速跑通原则,去公网申请证书太麻烦。

放弃了直接在手机端的浏览器测试,转用电脑端的浏览器进行调试和开发。

*Tips:为什么是局域网,因为是模型是本地部署且不开放公网链接*

02 | 自研 Agent 引擎:一个 While 循环的掌控力

① 核心循环只有 60 行:为什么不用 LangChain?

黑客松 48 小时最怕什么?

最怕框架内部报错,你翻半天源码都找不到问题出在哪。

在 app/agent/loop.py 里手写了 agent_loop 这个异步函数。

简单说,就是一个 `while True` 循环

code
# app/agent/loop.py - 核心 Agent Loop 循环片段while True:    msg_id = f"msg_{uuid4().hex}"    stream = llm.chat(history, tools=registry.schemas())    async for chunk in stream:        if chunk["type"] == "text_delta":            bus.push(session_id, _event("text_delta", {"turn_id": turn_id, "delta": chunk["delta"]}))        # 核心退出条件:模型本轮未发出任何 tool_calls 指令,退出循环    if not stream.tool_calls:        break    # 可选防死循环安全阀兜底    rounds += 1    if config.AGENT_MAX_ROUNDS and rounds >= config.AGENT_MAX_ROUNDS:        break

每一轮做三件事:把历史消息喂给模型、拿到模型的回复和工具调用指令、执行工具并把结果记下来。

退出条件也很简单:模型这一轮没有发出任何工具调用指令,循环就结束

核心循环代码只有约 60 行

整个 loop.py 加上上下文构造和消息落库,一共约 300 行

另外留了一个可选的安全阀:AGENT_MAX_ROUNDS 参数,默认关闭,按需启用。

一旦打开,超过设定轮数就强制中止,防止极端场景下模型陷入死循环。

② 端侧显存救星:上下文的"三重瘦身"

在 ATOM 本地超算上跑 Qwen3.6 这种多模态 MoE 模型,喂进去的上下文越长,Prefill 阶段显存占用越大。

其实,就是历史消息太多,显存会爆

项目设计了三重瘦身机制:

  1. 动态状态快照:每轮进模型前,_state_index() 只查"有哪些药、今天吃了几次、库存还剩多少这种索引级信息。细节按需调工具查。
  1. 工具记录只留 2 轮:历史里的工具调用记录,只保留最近 2 轮。更早的全部删掉,避免旧数据和新数据在上下文里打架。
  1. 图片自动降级:上传的照片只有当轮会发给 VLM。进入历史后,自动替换成一句话:(当时上传了 1 张照片),大幅降低显存开销。

③ 现场踩坑:vLLM 不解析工具调用,我们手写了 XML 解析器

黑客松现场我们踩到了一个坑的要死的 Bug。

现场 AIMA 端点没有启用 vLLM 的 tool-call-parser

Qwen3.6 明明正确输出了 <tool_call> XML 标签,但 vLLM 没把它转成标准的 tool_calls 字段,直接把 XML 原文塞进了 content 里。

结果就是:工具调用命中率 0%,整条链路瘫痪

怎么办?

在 app/llm.py 里手写了一个流式 XML 正则解析器。

简单说就是:服务端正常解析了,我就用服务端的;服务端没解析,我就自己从文本里抠 XML 标签,转成标准格式。

code
# app/llm.py - vLLM 缺少 tool-call-parser 时的流式 XML 正则捕获器_TOOL_CALL_RE = re.compile(r"<tool_call>\s*(.*?)\s*</tool_call>", re.DOTALL)_FUNCTION_RE = re.compile(r"<function=([^>]+)>\s*(.*?)\s*</function>", re.DOTALL)_PARAMETER_RE = re.compile(r"<parameter=([^>]+)>\s*(.*?)\s*</parameter>", re.DOTALL)def _parse_xml_tool_calls(text: str) -> list[dict]:    calls: list[dict] = []    for block in _TOOL_CALL_RE.findall(text):        for name, body in _FUNCTION_RE.findall(block):            arguments = dict(_PARAMETER_RE.findall(body))            calls.append({"id": f"call_{len(calls)}""type""function",                           "function": {"name": name, "arguments": json.dumps(arguments, ensure_ascii=False)}})    return calls

同时我们还关掉了模型的思考过程(enable_thinking: False)。不关的话,模型会在回复里塞进数千字的推理过程,直接把内存打满

03 | 代码底层的安全机制:绝不让模型自作主张

① 二段式确认门:模型只能提建议,不能拍板

怎么防止模型幻觉偷偷改了数据库?

说人话,就是:模型只有建议权,没有决定权。

在代码底层做了一个叫 ToolResult 的双面返回设计。工具执行完毕后,返回两张"脸":

  • 给模型看的脸llm_view):只有纯文字摘要,比如"已生成建档确认卡,等待用户确认"。
  • 给前端看的脸card):包含确认卡数据和一个不可猜测*随机 Token,通过 SSE 推送到手机端。

更严的是,真正往数据库里写数据的函数,根本没注册给模型。模型压根调不了。

code
# app/agent/registry.py - 工具返回的双面视图结构@dataclass(frozen=True)class CardEnvelope:    type: str                         # 卡片类型    payload: dict = field(default_factory=dict)    confirm_token: str | None = None  # 仅确认卡片携带,只透出给 SSE/前端,绝不进 LLM@dataclass(frozen=True)class ToolResult:    llm_view: str                     # 给 LLM 看的纯文字摘要(无 Token)    card: CardEnvelope | None = None  # 给前端看的卡片数据与 Token

必须由用户在手机上点击确认按钮,带着 Token 走 POST /confirm,系统校验 Token 时效后才真正落库。

② 线程池隔离:防止图像识别卡死整个服务

在 Python 异步编程里有个经典问题:本地 VLM 看图取证和 zxing-cpp 条码扫描都是同步阻塞任务。

如果直接在主协程里跑,会 卡死 FastAPI 的事件循环。SSE 心跳断掉、调度器挂掉、确认接口排队。

解法是 asyncio.to_thread

把同步工具丢进线程池跑,主事件循环照常运转。同时用 ContextVar 隔离图片上下文,保证线程间互不干扰。

③ 独立调度器:到点提醒绝不靠模型"记得"

在 app/core/scheduler.py 中,自研了一个后台常驻的 asyncio tick 调度器。

核心循环仅十余行,整个模块约 90 行

code
# app/core/scheduler.py - 后台常驻 12 行 tick 调度器async def run() -> None:    """每 SCHEDULER_TICK_SECONDS 秒扫描一次到期任务,独立于 LLM 运作"""    while True:        try:            tick()  # 扫描 pending 到期提醒任务并发出广播        except Exception:            logger.exception("调度器 tick 失败,跳过本次")        await asyncio.sleep(config.SCHEDULER_TICK_SECONDS)

它完全独立于 Agent,每隔 1 秒扫描数据库里的到期任务,直接通过 SSE 广播提醒卡片。不依赖大模型记忆

而在复核环节(app/core/verify.py),系统先让 zxing-cpp 扫描条码。

就是先经 Pillow 把照片转成灰度图,再让 zxing-cpp 扫 EAN-13 商品条码。如果扫不到码,才降级让 VLM 读盒上文字。最后才比对外观参考图。

code
# app/core/barcode.py - zxing-cpp + Pillow 本地灰度解算def decode(image: bytes) -> list[str]:    try:        with Image.open(io.BytesIO(image)) as picture:            results = zxingcpp.read_barcodes(                picture.convert("L"), formats=zxingcpp.BarcodeFormat.EAN13            )    except (UnidentifiedImageError, OSError, ValueError):        return []  # 扫不到码时优雅返回空列表,触发级联降级至 VLM 读字层    return [result.text for result in results if result.valid and result.text]

全部看不清,就直接拒绝判断,绝不硬猜。

写在最后

这 48 小时的极简架构实践,最大的一个收获是在于:在产品立项初期,关注点要一直放在MVP的核心链路实现上。一而再,再而三的克制想不断扩功能和增加技术复杂性的强烈念头。

在黑客松这类限时开发和产品初期立项里,做减法比做加法难得多,也值得多

不用臃肿的框架,核心循环就 60 行代码,每一行都清楚它在干什么。

现场演示零崩溃,所有功能全链路跑通,这才是 V1.0 版本要做的。

以上,就是今天的极简 Agent 技术实现分享。

提交反馈

图文拒绝 LangChain!48小时黑客松,零框架、单进程,手搓智能体的技术细节

0 / 2000