手机侧此前是一份只用标准库的薄内核,和 PC 上游双份维护迟早漂移;把完整内核装进去、又不改上游一行,得真机验证才敢拍板。这篇记从可行性 spike 到真机验收的攻坚,以及真机上炸出来的坑。
TL;DR
- 做了什么:把完整的 Python Agent 内核(fastapi / uvicorn / openai-agents 那一套真身,非裁剪版)打进 APK,在手机里以环回 HTTP 服务跑起来,零上游改动。
- 结果:真机验收通过——环回 chat 全链路跑通(BYOK deepseek-v4.1-flash 真实应答 + SSE 推流)、task 模式 agent 循环真实运行、工具面 20 个(9 个手机 GUI 原语 + 上游 core)。Phase 0 可行性 spike 全程 4.4s。
- 代价:依赖闭包攻坚——5 个自编 Rust wheel + 1 个社区现成 wheel + 2 个语义替身 + 官方 Android 仓 2 个原生包。
- 目前还没做完(诚实列) :手机 GateUI 缺位,Phase 1 以 security.mode=full_access 顶;上游 utils/__init__ 的 Windows 专属 eager import 仍靠 ctypes.windll 桩顶着(这是全平台可移植性 bug,Linux/macOS 同炸,待上游 3 行惰性化落地后撤);仅单台 arm64 真机验收,未做跨设备推广。
导言:四层
- 做了什么:一个操作手机的 Agent,把完整的 Python Agent 内核真身直接打进 APK,在手机本地环回 HTTP 跑起来——循环、工具、记忆、编排全用上游的,不搞手机专属副本。
- 问题:薄内核要和 PC 上游双份维护、慢慢漂移;能不能不 fork、直接上完整内核、且不改上游一行?
- 答案:能。真机验收通过,代价是上面那串依赖闭包攻坚。
- 定性一句:这不是「手机跑大模型」,是「手机跑完整 Agent 运行时(循环 + 工具 + 记忆 + 编排)」;而且零上游改动是关键约束——上游改一行,手机侧构建期自动跟上,永远没有「忘同步」。
1. 目标与闸门:先证明能成
手机侧此前是一份标准库薄内核(hachimi_kernel,只用标准库)。它的代价是和 PC 上游双份维护、慢慢漂移。这次的目标很明确:把上游完整内核真身打进 APK,零上游改动、单一真源——构建期从上游仓拷源码进 APK 的 python 源集,上游改了即生效。
但这事有几个事先不敢打包票的点,所以先设了一道可行性闸门(Phase 0 spike),不过就回炉:
- 第一道闸也是最硬的:pydantic-core 这种二进制 wheel,Chaquopy 官方仓没有 Android 版,要么重打平台标签、要么自编;
- 上游 utils 的导入雷:包导入期 eager import Windows 专属模块(ctypes.windll + psutil),Android 上 import utils 会直接炸——而且这是全平台可移植性 bug,Linux/macOS 同炸;
- 还得证明:完整 SDK 导入链 + 起 uvicorn + 环回 /health 能在真机跑通。
结果:Phase 0 五道闸全过、无桩运行,全程 4.4s。GATE_A pydantic-core 2.13.3 / core 2.46.3 的 Rust 扩展真机加载;GATE_C 完整上游导入链真实 SDK 无桩通过(utils + omni_core + router_runtime + chat_runtime + audio_api);GATE_D 环回 uvicorn /health → 200。闸门过了,才敢进 Phase 1 动真格。
顺带一个架构决定:本机内核 = 环回远程(127.0.0.1:<port>),遥控 PC 是同一个 client 只换 base_url,Tier 1 / Tier 2 同构。这是把手机当成「一台能跑完整 Python 服务的机器」,不是降级方案。
2. 依赖闭包:比预想深得多(攻坚主战场)
把完整内核装进去,第一道闸不是代码,是依赖闭包。纯 Python 那部分(fastapi / uvicorn / openai / httpx / PyYAML / starlette)走 PyPI 就完事;真正要命的是那些编译型扩展——而它们藏得比静态审计深。
清点方法(先说,免得被当成玄学)
实测闭包序列(这是最有价值的一段)
规律:LLM / Agent 生态 2025–2026 年 Rust 化严重,每个「纯 Python 好像够了」的判断,都要用 pip 实测闭包验证——这是 spike 阶段反复被打脸换来的。
最终落地:
- 自编 Rust wheel 5 个:jiter 0.18.0、rpds-py 0.30.0、ormsgpack 1.12.2、orjson 3.11.7、uuid-utils 0.14.1(abi3)
- 社区现成 1 个:pydantic-core 2.46.3(文件名要从 linux_aarch64 改成 android_24_arm64_v8a,pip 才收)
- 官方 Android 仓 2 个:psutil 7.1.3、numpy 1.26.2
- 语义替身 2 个:见下
语义替身:比编 C 扩展省一半力气的正当手段
前提:消费方对 API 的语义要求弱于位级兼容时,纯 Python 替身 + 官方开关是合法解。
- xxhash 3.6.0:langgraph 只消费 xxh3_128_hexdigest(pregel 状态版本指纹,只要求确定性 128 位摘要)→ 用 hashlib.blake2b(digest_size=16) 实现,py3-none-any wheel。与 PC 真 xxhash 摘要值不互通——无跨机语义,无影响。
- zstandard 0.25.0 stub:langsmith 的压缩导入是 try/except ImportError 守卫 + 官方开关 LANGSMITH_DISABLE_RUN_COMPRESSION=true——stub 满足 pip 依赖解析,守卫自然判 False,双保险。
判据(这一步不能省):替身前先 grep 消费方真实用法。langgraph 全仓只有一处 from-import;langsmith 有守卫 + 开关。并确认无跨系统兼容语义。
自编 wheel 的三个关键坑(五步法只说坑,不堆流水)
- pyo3 对 android 目标无条件 -lpython3.12:extension-module feature 压不掉(上游 build.rs 对 android 硬编码)。必须从自家 APK 抽 libpython3.12.so 喂进去,产物 DT_NEEDED 与 Chaquopy 运行库 soname 逐字匹配才 dlopen 命中。
- 扩展文件名必须裸 .so:<name>.cpython-312-aarch64-linux-android.so 这种完整标签名 Chaquopy 的 EXTENSION_SUFFIXES 不认(import 报 No module named),改成 <name>.so 即刻可见。
- maturin 1.16 在 Windows 有解释器名解析 bug(.exe 后缀),绕行 cargo 直编 + 手工组 wheel(从官方 manylinux aarch64 wheel 抽 python 文件与元数据,换入自编 .so,WHEEL 标签改 cp312-cp312-android_24_arm64_v8a,重算 RECORD)。
边界:以上针对 NDK r27c + Chaquopy 17.2 + 目标 Python 3.12,版本钉死(openai-agents==0.22.2 / openai==3.13.0 / mcp==2.2.0 …)。换 NDK 或升 Chaquopy 可能要重走一遍。
3. 三个真机踩坑(现象 → 后果 → 怎么修 → 教训)
踩坑段落的价值不在「我踩过」,在把因果链讲完整。下面三个都是真机逐轮报错收敛出来的,不是设计阶段能预判的。
坑 1:SSL_CERT_FILE(本次最深的一坑)
- 现象:agent 循环跑通后,LLM 调用 3 连败 APIConnectionError(流式退同步的日志可见);而裸 ssl.create_default_context() 直连同一端点 TLS 握手成功、httpx 也成功(401 = HTTP 正常)。
- 后果:链路「看起来通、实际调不通」,极迷惑——你以为网络配置对了,其实只有非 SDK 路径通。
- 根因:openai SDK 走 httpx2,httpx2 自建 ssl context 走「默认验证路径」——设备上 openssl_cafile=/usr/local/ssl/cert.pem 不存在 → unable to get local issuer certificate。Chaquopy 的 CA(cacert.pem)不在默认路径,httpx2 也不自动用 certifi。
- 修法:环境变量 SSL_CERT_FILE=certifi.where()(certifi 在 AssetFinder 有真实文件),prepare() 里设置,先于任何 context 创建。
- 诊断法:环回诊断路由 /debug-net 三层自检(裸 ssl / httpx / httpx2 + 显式 CA 对照),一次出全部结论。该路由保留(仅 127.0.0.1 可达)。
- 教训:交叉编译 / 定制运行时的 CA 信任链要显式接管,不能假设「系统有默认 CA」。凡是换了 Python 运行时或打包方式,TLS 是第一优先级验证项。
坑 2:CWD / HOME 三件套(顺序敏感)
- 现象:import utils 即 OSError: Read-only file system。
- 后果:内核根本起不来,连报错都来不及看业务层。
- 根因:上游 utils/logger import 时 makedirs('./temp')(相对 CWD),而 Android 进程 CWD=/ 只读。
- 修法:chdir 必须发生在任何 omni/utils 导入之前。Kotlin 侧序列:prepare()(零依赖,只动环境)→ bind 工具模块 → start;工具模块顶部再自保一份(import 即 chdir)。HOME 同理指向 App 私有目录。
- 教训:跨平台移植,先确认「隐式 CWD / HOME 依赖」藏在哪。这类依赖平时在桌面跑得好好的,一上定制运行时就炸,而且炸在最底层、最不显眼。
坑 3:服务就绪冒名
- 现象:omni_host 报 kernel up,/health 探活 200,实际端口上没东西。
- 后果:探活「通过」误导你以为内核起了,真正请求打过去全空。
- 根因:bind 失败(端口被残留监听占用)后 uvicorn 静默退出,而 /health 探活打到了还活着的旧监听者上——冒名顶替。Phase 0 的 spike 自检服务器(同端口)残留也撞过同一类:spike 的 should_exit 异步生效,正式服务立刻 bind 会 EADDRINUSE,探活又打到 spike 上假成功。
- 修法:就绪判断用 uvicorn Server.started 标志 + 线程存活检查,探活只作第二确认。验收完成后摘除测试挂钩。
- 教训:同一端口上先后两个服务交接时,「探活通过」无法区分新旧监听者。就绪信号要来自服务自身的状态机,不能来自「端口有人应答」。
4. 工具面:@function_tool 直接注册(AssetFinder 虚拟路径)
- 现象:Chaquopy 把 python 源码打进 assets/chaquopy/app.imy(zip),import 正常,但真实文件系统不存在——任何「扫描目录」的上游逻辑(插件 loader 的 plugins/ 目录扫描、Path(__file__).parent.glob)在设备上静默落空。
- 后果:上游插件 loader 在手机上扫不到任何插件,工具面为空。
- 修法:工具注册改走 @function_tool import 即注册(TOOL_REGISTRY 进程内表),不依赖 loader 目录扫描;数据文件走 certifi.where() 等运行时解析的真实路径。
- 数据:手机工具面 20 个——9 个手机 GUI 原语(observe / wait / tap_by_id / tap_xy / type_text / gesture / screenshot / launch_app / press)+ 上游 core 工具。PC 专属工具(如 shell_exec)在手机上会注册但执行失败——失败是结构化的(回 agent 自主换路),不炸循环;后续按「工具面 = 注册 ∩ 通道门」纪律收窄。
- 教训:定制打包器改变了「文件在磁盘上」这个隐含前提。凡是依赖 __file__ 相对路径或目录扫描的逻辑,移植时都要逐处排查。
5. 边界(诚实写,不夸大)
- 机型 / 样本量:单台 arm64 真机(与系列前文同设备)、Android,N=1 验收;不做跨设备推广。
- Tier 边界:Phase 1 以本机环回(Tier 1)为主;Tier 2 遥控 PC 复用同一 wire contract;mini_loop 兜底待 Phase 2 退役。
- GateUI 缺位:手机审批面未做,Phase 1 以 security.mode=full_access 显式全权顶上,审批面后续独立做。
- 上游可移植性 bug 暂用桩:utils/__init__ 包导入期 eager import window_finder(模块级 ctypes.windll + psutil)——Android 上 import utils 即炸,目前用 ctypes.windll = SimpleNamespace(user32=None) 桩顶着。这是全平台 bug(Linux/macOS 同炸),正解是上游 3 行惰性化,落地后撤桩。
- 网络是独立环境事实:同一 BYOK 端点 PC 可达 ≠ 手机可达(某端点手机 TCP 443 间歇超时,需重试 / 切设备可达的 provider)。另:gitclaw 网关非流式响应把 OpenAI 载荷包在 {"data":{...}} 里,流式则标准 SSE 无包裹——设置页非流式测试因此误报「响应不是 OpenAI 兼容格式」,而内核走流式不受影响。测试桩与正式链路传输方式不同(流 vs 非流),结论可能相反,验证端点必须流式与非流式各测一次。
写在最后
把一整套 Agent 运行时装进手机、靠构建期同步保单一真源,工程上最重的不是写代码,是上面这几层依赖与运行时的攻坚——每一层都是真机逐轮报错收敛出来的,不是设计阶段能预判全的。
按层的症状 → 根因 → 解法 → 沉淀规律,完整记录在项目仓库的 doc/full_kernel_hosting_pitfalls_*.md,换机换端点照其最小验收序复跑即可。
项目地址:github.com/momo-null/hachifish
本文由 AI图鉴自动抓取并翻译,版权归原作者所有;如需查看原文请点下方链接。
The cell phone was previously a thin inner core with only a standard library and double maintenance upstream of the PC; the complete kernel should be loaded in without changing the upstream line, and the board must be authentic. It's from the feasibility of the spike to the attack of the real machine acceptance check, and the pits from the real machine.
TL;DR
- What did it do: put the complete PythonAgent kernel (fastapi / uvicorn / openai-agets) into APK, run back to HTTP service on the phone and change up zero.
- Outcome: Real acceptance check runs through - loop back to chat full-link (BYOK deepseek-v41-flash True Response + SSE Push), task mode angent cycle real running, tools 20 (9 cell phones GUI + upstream core). Phase 0 feasibility spike full 4.4s.
- Cost: Reliance on closed-locking attack - 5 self-organised Rust Wheel + 1 community is now available for wiel + 2 semantic stunts + 2 original kits for the official Android warehouse.
- Not done at the present time (in honesty column): GateUI missing cell phone, Case 1 = top of security.mode = full access; upstream utils/ init dedicated Windows eager import still has ctypes.windll stakes (this is full platform portability bug, Linux/macOS co-bombing, waiting for 3 rows of inertia to fall back); single arm64 machine acceptance check, not extended equipment.
Introduction: four floors
- What has been done: an Agent who operates a mobile phone, directs the complete Python Agent kernel into APK, runs back to HTTP in the local ring of the cell phone -- recycles, tools, memories, layouts all upstream, without making exclusive copies of the phone.
- Question: Do you want to maintain and slowly float both the thin kernel and the PC upstream; can you not fork away, go directly to the complete kernel and not change the row upstream?
- Answer: Yes. The real machine acceptance check passed at the cost of the line above relying on a closed-lock attack.
- Qualitative sentence: This is not "cellular run LLM", it's "cell phone run full Agent run time" (cycle + tool + memory + organization); and Zero upstream changes are the key constraint — moving up the line, the cell phone side construction period automatically follows, and never forgets to synchronize.
1. Targets and gates: Prove success first
The cell phone side was previously a standard library core (hachimi kernel, only the standard library). It's at the cost of double maintenance and slow drift with the PC upstream. This time, the goal is clear: the upstream complete kernel goes into APK, the zero-upstream change, the single real source — the construction period moves from the upstream warehouse code to the python source set of APK, effective upstream.
But there are a few points that you can't pre-pack, so there's a feasibility gate, but it's back:
- The first gate is also the strongest: pydantic-core, a binary, sheel, Chaquopy official warehouse does not have an Android version, either replaying the platform label or making it up;
- Import mines for upstream utils: eager import Windows exclusive module (ctypes.windll + psutil), Android effect utils will explode directly -- And this is a platform-wide portability bug, Linux/macOS co-bombing;
- Also have to prove that the full SDK import chain + up uvicorn + loop back/headth runs through the real machine.
Result: Phase 0, five gates are running across, unposted, all 4.4s. GATE A pydantic-core 2.13.3 / core 2.463; GATE C complete upstream import chain real SDK without stakes (utils + omni core + root runtime + chat runtime + adio api); GATE D loop back uvicorn/health 200. When the gate is over, you can't even get in to Phase 1.
By a structure decision: This inner core = loop back remote (127.0.0.1: <port>), Remotely controlled PC is the same client that changes base url, Tier 1 / Tier 2. It's a "mechanism that runs the whole Python service", not a downgrade.
2. Reliance on closed bags: much deeper than expected (to attack the main battlefield)
The first gate is not code, it is closed. Purely Python, that part is finished; what's really important is those compilers -- they hide deeper than static audits.
Method of accounting (first, so as not to be considered genre)
(this is the most valuable part)
Patterns: LLM / Agent Ecology 2025-2026 Rust is severely degraded, and every "pure Python seems to be enough" judgement is validated with a pip insinuation package -- this is a phase of spike that is repeatedly replaced by face-to-face.
Final landing:
- Self-edited Rust Wheel 5:jiter 0.18.0, rpds-py 0.30.0, ormsgpack 1.12.2, orjson 3.11.7, uid-utils 0.14.1 (abi3)
- Community 1: pydantic-core 2.463 (file name changed from linux aarch64 to android 24 arm64 v8a, pip)
- Official Android warehouse 2: psutil 7.1.3, numpy 1.26.2
- Semantic doubles.
Semantic Substitute: Adding half of his strength to his right hand
Presupposition: When the semantic requirements of the consumer for API are weaker than the level compatibility, the pure Python + official switch is legally solved.
- xhash 3.6.0: langgraph only consumes xxh3 128 hexdigest (pregel state version fingerprint, requiring only certainty 128 bit summary) – achieved by Hashlib.blake2b (digest size=16), py3-none-any sheel. It doesn't make sense with PC real xhash summary -- no cross-linguistic, no impact.
- zstandard 0.25.0 stub: langsmith compression is try/except ImportError guard + official switch LANGSMITH DISABLE RUN COMPRESSION=true-stub meets pip reliance on resolution, guard natural sentencing False, double insurance.
The sentence (this step cannot be saved) is the actual usage of the grep consumer before the replacement. langgraph has only one full warehouse from-import; langsmith has guards + switches. It was also recognized that there was no cross-system language.
Three key pits of self-programming wheel.
- Pyo3 Unconditional for android target -lpython3.12:xtension-module feature (upstream build.rs against android hard code). The product DT NEED matches the Chaquapy run library soname word for dlopen hit.
- The extended file name must be nudised.so: <name>.cpython-aarch64-linux-android.so This complete label, Chaquoy, EXTENSION SUFFIXES, is immediately visible.
- maturin 1.16 In Windows there is an interpreter solver bug (.exe suffix), bypassing cargo directly + manual group wheel (from official manylinux aarch64 wheel to python files and metadata for self-edited.so, WHOEL label changed cp 312-cp312-android 24 arm64 v8a and recalculated RECORD).
Border: above for NDK r27c + Chaquapy 17.2 + target Python 3.12, version nailed (openai-agents==0.22.2 / openai==3.13./ mcp==2.2.0...). NDK or Chaquapy might have to go again.
3. Three real-time pits (results of phenomena, how to repair lessons)
The value of the pedal section is not "I've stepped on" and it's complete with the causal chain. The following three are the result of a series of false reports, not a prediction at the design stage.
Pit 1: SSL CERT FILE
- Symptoms: After angent loop runs, LLM calls 3 consecutively loses APIConectError (flowing back synchronous log visible); and nudity ssl.create default context() goes handshake with an end point and httpx is also successful (401 = HTTP normal).
- Consequences: The link "looks and actually doesn't make sense" is very confusing -- you think the network is right, but only the non-SDK path.
- Ghen: Openai SDK Go httpx2, httpx2 build ssl constext Take the "default authentication path" - opensl cafile=/usr/local/sl/cert.pem does not exist on the device unable to get local issuer certificate. Chaquapy's CA (cacert.pem) is not on the default path and httpx2 does not automatically use certifi.
- Modification: Environmental variable SSL CERT FILE=certifi.where() (certifi has authentic files in AssemblyFinder), prepare() set before any context.
- Diagnosis: a three-tier self-censorship of loop-diagnosis route /debug-net (nature ssl / httpx / httpx2 + visible CA contrast), all at once. This route is reserved ( 127.0.0.1 accessible only).
- Lessons learned: Cross-compile / customised CA trust chains run with a visible takeover, which cannot be assumed as "the system has default CA". TLS is the first priority authentication entry for all Python running or packing.
Pit 2: CWD/HOME 3 sets (sequence sensitive)
- Symptoms: reports OSError: Read-only file system.
- Consequences: The kernel can't rise at all, it can't even report its mistakes before it can look at the business level.
- Root: makedirs ('./temp') (relative to CWD) upstream utils/logger report, while Android process CWD=/ read-only.
- Amending: Chdir must have occurred before any omni/utils import. Kotlin side sequence: prepara() (zero-dependent, environment-only) bind tool module →start; tool module top save one more (import or chdir). HOME points equally to the App private directory.
- Lessons learned: Trans-platform transplant, first confirming where the "hidden CWD/HOME dependency" is hidden. This type of dependency runs well on the desktop on a regular basis and explodes as soon as it is customised and at the bottom and least visible.
Pit 3: Service ready for impersonation
- Symptoms: Monni host newspaper kelnel up,/health probe 200, no actual port.
- Consequences: The mission misled you into thinking that the core had been set up, and you really asked for it to be empty.
- Gene: bind failed, uvicorn left silently, and/or health agents hit an old listening person alive -- impostor. Case 0 self-checking server (same port) residues have also been collided with the same type: should exit walk-in effect for spike, official service immediately bind EADDRINUSE, and detection has been successful on spike.
- Reform: the inspection is conducted using the uvicorn Server.starded sign + linear survival check, only second confirmation. When acceptance check is completed, remove the link from the test.
- Lessons learned: When two services are handed over at the same port, "surveillance passes" cannot distinguish between old and new listeners. The readiness signal must come from the service provider and not from the "port answerer".
4. Tools: @force tool direct registration (AssemblyFinder virtual path)
- Symptoms: Chaquapy pushes python source code into asets/chaquapy/app.imy (zip), import is normal, but the real file system does not exist — any upstream logic of “scan directory” (plugin plugins/ directory scan in loader, Path( file ).parent.glob) is silently absent on the device.
- Consequences: upstream plug-in loader cannot sweep any plug-in on his mobile phone and the tool surface is empty.
- Modification: The tool registration has been changed to @force tool report, i.e. registration (toOL REGISTRY process internal table), without relying on loader directory scans; data files go certifi.where() the true path to resolve when running.
- Data: 20 tool surfaces for mobile phones - 9 GUIs in original language (observ / wait / tap by id / tab xy / type text / gesture / screenshot / lanch app / pres) + upstream core tools. PC proprietary tools (e.g. shell exec) are registered on a mobile phone but the execution fails - failure is structured (back angent auto-replacement) and does not blow up the cycle; then narrow down by the discipline of "tool face = registered ∩ portal".
- Lessons learned: custom packers change the implicit premise of "document on disk." Any logic that relies on the file relation path or directory scan is checked everywhere.
5. Boundaries (in good faith, not exaggerated)
- Type / sample: single arm64 (serial prefix), Android, N = 1 acceptance check; no extension.
- Tier boundary: Phase 1 is dominated by the return of the ring (Tier 1); Tier 2 remote control PC is reused by the same wire limit; Mini loop is forced to retire from service to Phase 2.
- GateUI's missing: Cell phone approval not done, Chase 1 tops up with security.mode = full access and independently.
- Upstream portability bug stakes: utils/ init package import period eager import window finder (module level ctypes.windll + psutil) - impact utils on Android, currently held by ctypes.windll = SimpleNamespace (user32=None). This is a platform full of bugs (Linux/macOS co-bombing) and correct is 3 rows upstream inertity and drop the pillars.
- Networks are independent environmental facts: the same BYOK endpoint PC can be reached by a cell phone (some endline phone TCP 443 breaks timeout, requires retry / cutable device to reach provider). Also: the gitclaw gateway's non-flowable response packs Open AI loads in {"data": }}, the flow standard SSE is not packaged – set page non-flow tests are therefore misreported as "responses are not Open AI compatible formats" and kernel flow is not affected. The test stakes differ from the official chain transfer mode (flows vs non-flow), and the conclusion may be that the validation endpoints must be measured in both flow and non-flow mode.
At the end.
Put a whole set of Agent's running time into a cell phone and synchronize it with the construction period. The most important thing on the project is not writing codes, it's relying on and running on these layers. - Every floor is a chain of error, not a design phase.
By layer of symptoms root causes decomposition sedimentation patterns, fully recorded in project warehouse doc/full kernel hosting pitfalls *.md, rerun the replacement endpoint in order of its smallest acceptance check.
Project address: Github.com/momo-null/hachifish
Auto-fetched and translated by AI Atlas. All rights belong to the original author.