大家好!我是瓜哥。前互联网技术副总裁,现在带队死磕 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 阶段显存占用越大。
其实,就是历史消息太多,显存会爆。
项目设计了三重瘦身机制:

- 动态状态快照:每轮进模型前,
_state_index()只查"有哪些药、今天吃了几次、库存还剩多少这种索引级信息。细节按需调工具查。
- 工具记录只留 2 轮:历史里的工具调用记录,只保留最近 2 轮。更早的全部删掉,避免旧数据和新数据在上下文里打架。
- 图片自动降级:上传的照片只有当轮会发给 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小时黑客松,零框架、单进程,手搓智能体的技术细节