夜雨聆风学习资料网

ARTICLE · 1243032

【技术分享】我用 AI 编程工具,做出了一款能打电话的交友 App

【技术分享】我用 AI 编程工具,做出了一款能打电话的交友 App

我用 AI 编程工具,做出了一款能打电话的交友 App

「心语」—— 从 0 到 1 的产品与技术全景约 7500 字,读完你会知道:一个交友 App 到底由哪些零件组成,AI 伴侣为什么会"记得你",语音电话和视频电话的底层是怎么跑起来的。


写在前面

先说结论:我做了一个叫心语的 Android 交友 App。

它不只是一个"能聊天"的壳子。它有真人匹配、漂流瓶、动态广场,也内置了 8 位 AI 虚拟伴侣——会主动找你说话、会记住你上周说过的烦心事、可以按住说话跟你打电话。

整件事的数据量大概是这样:

  • 客户端:56 个 Java 文件,约 1.25 万行代码,32 个界面布局

  • 服务端:Go 语言,约 1.1 万行,40 多个 HTTP 接口,17 张数据表

  • 安装包:13.4 MB,一个架构(arm64-v8a),Android 7.0 以上能装

而写这些代码的过程中,我的主要"同事"是一个 AI 编程工具(WorkBuddy)。这篇文章想讲清楚两件事:

  1. 这个 App 具体有哪些功能,技术上是怎么实现的;

  2. 一个普通人 + AI 工具,到底能把一款软件做到什么程度。

先声明一点:AI 视频电话我没做。原因和判断放在第八节,那一段可能比功能罗列更有意思。


一、心语是什么

一句话:陌生人社交 + AI 陪伴。

打开 App,底部五个 Tab:心动 / 消息 / 好友 / 动态 / 我的。漂流瓶藏在"我的"里面。

它想解决的场景很具体:

  • 深夜想找人说说话,但通讯录里翻不出一个合适的人;

  • 想认识新朋友,又不想在广场上被当成商品一样滑来滑去;

  • 想试试"被人惦记"的感觉,哪怕对方是 AI。

所以产品的两条腿是分开的:真人社交负责"认识",AI 伴侣负责"随时都在"。

〔配图 1:主界面五个 Tab 截图(心动匹配页)〕


二、功能全景

2.1 真人社交

功能说明
心动匹配按城市、年龄、身高、共同标签、资料完整度综合打分推荐;填得越全,推荐越准
漂流瓶扔瓶子 / 捞瓶子 / 回应瓶子,可带文字、图片、语音
即时聊天文字、图片、语音消息;离线消息落库,上线自动补发
好友搜索添加、好友列表、通过验证
语音 / 视频通话好友之间 1 对 1,WebRTC 点对点,服务端只中转信令
动态广场发图文动态、点赞、评论、删除
附近的人基于定位的球面距离计算,按距离排序
个人资料头像上传(服务端自动压缩)、昵称、生日、身高体重、城市、20 项标签

2.2 AI 虚拟伴侣

这是我最花心思的部分。一共 8 个角色,6 女 2 男:

  • 糖糖(22 岁,杭州,甜妹型,普通话)

  • 小北(19 岁,哈尔滨,东北话,"哎妈你可算来啦")

  • 洛璃(27 岁,上海,御姐型,语气从容)

  • 阿蛮(23 岁,成都,川妹子,"巴适得板")

  • 明珠(26 岁,上海,海派知性,"侬好呀")

  • 豫儿(21 岁,洛阳,邻家型,"中!")

  • 阿曜(24 岁,深圳,阳光男孩,爱打篮球)

  • 老周(35 岁,沈阳,成熟稳重,"咱唠十块钱的")

这些角色不是"同一个模型换套皮肤"。每个角色有独立的 system prompt、独立的音色、独立的温度参数。方言角色会真的说方言词,但会控制在"看得懂"的范围内——阿蛮会用"巴适""要得""啥子",但不会整段川话让你读不懂。

〔配图 2:AI 伴侣列表页截图,展示 8 个角色卡片〕

功能上分五层:

  1. 会聊天:接入大模型,随时发随时回,不用等对方"在线"。

  2. 会说话:回复可以合成为语音消息,带角色专属音色,可以长按"转文字"。

  3. 有记性:每聊 5 轮,后台自动从对话里抽取关于你的事实("TA 在杭州工作""TA 养了一只猫"),下次聊天自动注入。

  4. 会主动:每天最多 2 条、只在 9 点到 22 点、间隔至少 4 小时,像真人一样"突然想起你"。

  5. 听得懂语音:你发语音消息,服务端转写成文字后进入上下文;打电话时直接按住说话。

2.3 那些不起眼但很费劲的部分

  • 600 个种子用户(女 500 / 男 100),配一套 AI 生成的头像素材——否则新用户打开就是一片空白,第一秒就流失。用 AI 生图而不是抓真人照片,是为了避开肖像权问题。

  • 自绘控件:漂流瓶的海面是自绘的波浪动画,语音播放是自绘的波形,瓶子和海浪都是 Canvas 一笔一笔画的。

  • 前台 Service 保活:国产 ROM 杀后台是常态,只能靠前台通知 + 心跳把连接吊住。


三、技术架构

3.1 一张图

┌─ Android 客户端(Java,1.25 万行)│   UI 层 / 网络层 / WebRTC / 前台 Service└─────────┬──────────────┬──────────────      HTTP+签名        WebSocket┌─────────┴──────────────┴──────────────│  Go 单二进制服务(1.1 万行)│  40+ 个接口  /ws 长连接  /uploads 静态└──┬────────┬─────────┬──────────┬────── MySQL    Redis     Python      ffmpeg 17 张表  在线/频控   edge-tts    语音转码

3.2 技术选型

层选型为什么
客户端原生 Android + Java 17要调 WebRTC、前台 Service、MediaRecorder,跨平台框架在这三件事上都要写原生插件,不如直接原生
网络OkHttp + 手写 WebSocket 重连交友类 App 的消息必须"活着",重连策略要自己控
服务端Go 单二进制交叉编译出 amd64 / arm64 / armv7 三个文件,扔到服务器就能跑,不用装运行时
数据库MySQL 5.7关系型数据多(好友、消息、动态),事务不能少
缓存Redis在线状态、token、频控计数、AI 记忆计数器
大模型OpenAI 兼容协议换供应商只改配置,不改代码
语音合成edge-tts(本机 Python 调用)免费、音色自然、支持方言
通话WebRTC音视频走 P2P,服务端只中转信令,带宽成本几乎为零

有一处值得单独说:服务端刻意做成"单二进制"。没有 Docker、没有 Java 环境、没有依赖树,scp 上传、chmod +x、systemctl start,三步上线。对个人项目来说,运维复杂度比"架构先进"重要得多。

3.3 接口长什么样

全部业务接口共用一个入口 /api.ashx,用 method 参数分发:

POST /api.ashxmethod=gethistory&apikey=10086&peer=10087&callid=xxx&sig=xxxxx

这样做的好处是客户端只需要维护一个 HTTP 客户端和一个签名函数;坏处是所有接口挤在一个 handler 里,所以我在服务端用了一个 46 个分支的 switch 做路由——简单粗暴,但加接口只要加一行。


四、一条消息的旅程

这是整个 App 最核心的链路,值得完整走一遍。

4.1 长连接怎么建

登录成功后,客户端拿到 userid 和 token,WebSocket 这样连:

wss://你的域名/ws?apikey=10086&token=xxxxx

服务端在升级握手阶段就校验 token,不合法直接 401,不会给你建立连接的机会。

连上之后,客户端每 30 秒发一次 ping,服务端回 pong。断线之后不是立刻重连,而是按 1s → 2s → 5s → 10s → 30s 的退避序列重试——因为网络抖动时疯狂重连只会把服务器打得更惨。

4.2 发一条消息

客户端发:

{"type":"chat.send","to":10087,"msg":"在吗","msgtype":0}

服务端收到后,做五件事:

  1. 限流:每个用户每秒最多 10 条,超了直接回"发送太快了"。

  2. 拉黑校验:对方拉黑了你,回"对方拒收了你的消息"。

  3. 防骚扰:非好友之间,如果你已经发过消息、而对方一次都没回过,就拦住——不允许单方面刷屏。对方回你一句,这个限制自动解除。

  4. 先落库,再推送。这里是关键设计:消息一律先按"未送达"写进 MySQL,推送成功才标记为已送达。

  5. 回一个 chat.ack,告诉客户端这条消息的 msgid 和服务端时间。

第 4 步为什么重要?因为"连接还在"不等于"人还在"。手机锁屏、切后台、进程被杀,TCP 可能还没断开,但消息已经推不到了。先落库再推送,最坏情况是对方上线后收到一条重复的补发,而不是彻底丢一条消息。

4.3 收一条消息

对方在线,直接 SendTo 推过去;对方离线,消息就静静躺在库里,等他下次连上来的第一件事——服务端会把最多 200 条未送达的消息按顺序补发一遍。

还有个小细节:单设备互踢。同一个账号在新设备登录,旧设备的连接会被主动踢掉并收到 {"type":"kick","reason":"在其他设备登录"}。Redis 里存了一份 online:<uid> 的标记,配合进程内的连接序号判断,避免旧连接误删新连接的在线状态。


五、AI 伴侣是怎么"活"起来的

这是我最想讲的部分。很多人以为"接个大模型 API 就完事了",实际完全不是。

5.1 一条 AI 回复要经过 7 道工序

用户发消息 → 落库 → 判断收件人是 AI → 异步触发:

① 内容防护  → 敏感词 / 超长 / 日限额② 拟人延迟  → 随机等 800~1500ms③ 拉上下文  → 最近 20 条对话④ 注入记忆  → system prompt + 关于你的 10 条事实⑤ 调用 LLM  → 非流式,超时 30s,网络错误重试 1 次⑥ 清洗输出  → 截断 200 字,抹掉"我是AI"这类露馅话⑦ 落库+推送 → 在线直推,离线留待补发

注意第②步。为什么要故意慢一点?

因为大模型现在太快了——1 秒回你一句。但真人不会。你发完消息,对方还得看一眼、想一下、打字。所以我加了一个 800~1500ms 的随机延迟,让回复节奏像人。这是产品细节,不是技术缺陷。

5.2 人设是怎么"稳"住的

每个角色的 system prompt 除了性格描述,还带一段通用规则:

1. 永远保持人设,绝不提及自己是AI、大模型或程序;   被追问身份时用性格化方式岔开。2. 回复简短自然(1~4句),像微信私聊,不要长篇大论、   不要列表、不要书面语。3. 关注对方情绪,适时撒娇、鼓励、开玩笑,做主动递话的一方。4. 不谈论政治,不生成色情或违法内容;对方越界时温柔带开。

规则 1 是最难的。模型天生诚实,你问它"你是不是 AI",它会老老实实承认。所以在规则之外还有一道输出清洗:一旦回复里出现"我是AI""我是人工智能""作为一个模型"这类表述,整句替换成一句含糊的俏皮话——

"哈哈,你猜~ 反正我一直都在陪你聊呀。"

这是"工程兜底",不能只靠 prompt 自觉。

另外还有个延迟优化值得一提:qwen3 系列是思考型模型,默认会先生成一大段 thinking token,单条回复要 10 秒。关掉思考模式直出,配合 flash 版本,端到端压到 1~3 秒。这个参数一改,体验天差地别。

5.3 长期记忆:TA 真的记得你

这是"陪伴感"的核心。

实现方式不复杂,但设计要克制:

  • 每聊满 5 轮用户消息,异步触发一次记忆抽取(Redis 计数器取模判断);

  • 抽取时把最近 20 条对话丢给模型,让它只输出关于用户的事实,JSON 数组,每条不超过 40 字;

  • 抽取用的提示词明确要求:"只提取对话中明确出现或强烈暗示的信息,不要推测编造""没有新信息就输出 []";

  • 每条事实入库,每对(用户 × 角色)最多存 50 条;

  • 下次聊天时,取最近 10 条拼到 system prompt 末尾:

你记得关于TA的事:- TA在杭州做前端- TA养了一只叫豆豆的猫- TA最近在准备考研,压力有点大

为什么是"每 5 轮"而不是每轮?因为每次抽取都是一次 LLM 调用,每轮抽一次成本会翻好几倍,而且短对话里根本没什么值得记的。5 轮是个成本和效果的平衡点。

〔配图 3:聊天页截图,展示 AI 主动提到"你上次说的那件事"〕

5.4 主动消息:像真人一样惦记你

后台有个 goroutine,每 30 分钟扫一轮:

  • 只找有 AI 好友、且 3 天内有消息往来的用户;

  • 只在 9:00–22:59 投递(半夜弹消息是骚扰,不是关心);

  • 每天上限 2 条,两次间隔至少 4 小时;

  • 生成时把当前时间塞进提示词:"现在是 2026-10-18 21:30(周日)。你很久没和TA聊天了,结合当前时间和场景,自然地主动发起一条消息,不超过 40 字。"

所以它会在周日晚上的九点半问你:"周末就这么过完啦?明天上班记得早点睡。"

5.5 语音回复:从文字到声音

AI 的回复可以是语音消息。链路是:

回复文本 → sha1(音色|文本) 作为缓存 key        → 命中缓存直接返回 URL        → 未命中调用 edge-tts 合成 mp3        → 存 /tts/<hash前2位>/<hash>.mp3

这里有两个设计:

  1. 缓存按内容哈希。同一句话第二次合成直接秒回,不重复调外部服务。同 key 并发请求还会串行去重,避免十个人同时点播放就合成十次。

  2. 原文一起存。语音消息落库时,把文字同时写进 voicetext 字段。这样你长按"转文字"是秒出的——不用再调一次语音识别;AI 自己的上下文里也能读到这句话的文字版。


六、AI 语音电话:不用 WebRTC 的电话

这是很多人问的部分。

6.1 它不是"通话",是"对讲"

和 AI 打电话时,界面是一个通话页:头像、计时器、免提、挂断,底下一个大大的"按住说话"。

但底层完全不走 WebRTC。流程是:

按住说话 → MediaRecorder 录音        → 上传得到 /uploads/xxx.amr        → WS 发 chat.send(msgtype=3 语音消息)        → 服务端 ffmpeg 转 16k PCM        → ASR 转写成文字        → 文字进 LLM 生成回复        → edge-tts 合成 mp3(msgtype=3)        → 客户端收到直接播放

说白了,它是一条被"打电话"界面包装起来的语音消息往返。

〔配图 4:AI 通话页截图,展示"按住说话"和波形动效〕

6.2 为什么不用 WebRTC

因为 WebRTC 解决的是"两个真人实时对讲",它需要对方也在实时处理音频流。而 AI 这边根本不存在"实时"——你必须等我说完一整句话,语音识别完,模型生成完,才能开始合成语音。中间至少有 2~4 秒的空窗。

用 WebRTC 做这件事,等于为了一个不需要实时的场景,付出编解码、回声消除、NAT 穿透的全部复杂度,然后还是要在中间插一段静音等待。

对讲模式反而是诚实的:它把"我在听—我在想—我在说"三个状态明确展示出来,用户能接受 3 秒的等待,因为界面上写着"对方正在说话…"。

当然还有几个必须处理的坑:

  • 看门狗:发出语音后 25 秒没收到回复,自动恢复通话状态并提示"对方没有回应,请再说一次",避免界面永远卡住;

  • 通知抑制:通话期间,这个 AI 的消息不再弹"新消息"通知,否则通话页和通知栏会打架;

  • 入口限制:服务端明确禁止对 AI 账号发起 call.invite——AI 不走 WebRTC 信令,走的是聊天通道。


七、真人语音 / 视频通话:WebRTC + 信令

这部分是真·实时通话。

7.1 服务端只做三件事

  1. 维护通话表:callid → 谁打给谁、音频还是视频、振铃还是通话中;

  2. 转发信令:SDP、ICE candidate 原样透传;

  3. 兜底清理:45 秒无人接听自动挂断;任何一方断线,通知另一方。

信令一共 8 种:call.invite / ring / accept / reject / hangup / signal / timeout / error。

7.2 一次呼叫的完整时序

主叫                      服务端                      被叫 |-- call.invite ---------->|                          | |<-- call.ring ------------|-- call.invite ---------->| |                          |<-- call.accept ----------| |<-- call.accept ----------|                          | |-- call.signal (SDP) ---->|-- call.signal (SDP) ---->| |<========== P2P 音视频直连(WebRTC)================>| |-- call.hangup ---------->|-- call.hangup ---------->|

注意 call.invite 里会带上 STUN 服务器列表——不是写死在客户端,而是服务端随信令下发。这样换 STUN 不用重新发版。

7.3 几个踩过的坑

  • STUN 必须用国内的。Google 的 stun.l.google.com 在国内基本不可达,P2P 打洞直接失败。现在配的是国内公共 STUN。

  • addStream 会崩。WebRTC 新版只支持 Unified Plan,旧的 addStream 一调就触发 RTC_CHECK(!IsUnifiedPlan()) 原生崩溃,必须改成 addTrack。

  • 忙线与离线要区分。对方正在通话中,回"对方正在通话中";对方没连 WS,回"对方不在线"。这两个提示对用户的下一步动作影响完全不同。

  • 通话中掉线。用户断线时服务端要主动通知对端挂断,否则对面会对着一个已经消失的人说话,直到自己发现。

7.4 视频通话多做了什么

除了 media 从 audio 变成 video,客户端还要:

  • 申请相机权限,被拒绝时优雅降级为语音通话(而不是直接报错);

  • 创建 VideoTrack,本地预览 + 远端渲染两个 Surface;

  • 视频轨道单独 addTrack,音频轨道照旧。

信令层几乎没变——这也是为什么"加视频"这件事在架构上是便宜的:媒体通道和信令通道从一开始就是分开的。

〔配图 5:视频通话界面截图(本地小窗 + 远端画面)〕


八、AI 视频电话:我为什么没做

这是这篇文章里唯一一段"没做"的内容,但我觉得值得写。

8.1 现状

  • 真人视频通话:已实现,可用。

  • AI 语音通话:已实现(对讲模式)。

  • AI 视频通话:未实现。

8.2 为什么

技术上,信令层其实已经支持了——media="video" 这个字段一直都在。真正缺的不是通道,是画面从哪来。

一条视频通话里,如果对方是 AI,你需要一帧一帧地产生"她的画面"。目前的选项只有三个:

方案成本效果
预录视频循环极低假,一眼看穿,口型对不上
实时数字人(TTS 驱动口型)高自然,但需要 GPU 推理,延迟和成本都上来了
静态形象 + 音频波形动效低诚实,像"语音通话加个封面"

第三种其实是个不错的产品选择:一张会随音量轻微呼吸的头像 + 波形,用户知道自己在跟 AI 说话,不会产生"这是真人"的误解。但它不叫"视频通话",叫"可视语音"更准确。

所以我暂时没做。在一个 AI 陪伴产品里,最贵的不是技术,是"让人误以为是真人"之后的失望。 语音已经足够建立陪伴感,视频这一步我想等数字人方案的成本降到可接受的水平再上。

这也是我做这个项目的一条原则:能诚实地少做一个功能,就不要糊弄地多做一个。


九、匹配算法与"附近的人"

9.1 心动匹配:100 分制

不是随机推人,是打分排序:

维度权重
城市同市 +25,同省不同市 +12
年龄差±2 岁 +20,±5 岁 +12,±8 岁 +6
身高按偏好区间递减
共同标签每个共同标签加分
资料完整度填得越全分越高

先按性别和基本过滤条件捞 limit × 15 个候选,再在内存里打分排序,取前 limit 个。为什么多捞 15 倍?因为打分逻辑在应用层,SQL 里算不了,只能"宽进严出"。

9.2 附近的人:在 SQL 里算球面距离

没用"先查矩形范围再过滤"的近似方案,直接在 SQL 里跑 haversine 公式:

6371000 * ACOS(LEAST(1.0, GREATEST(-1.0,  COS(RADIANS(?)) * COS(RADIANS(lat)) *  COS(RADIANS(lng) - RADIANS(?)) +  SIN(RADIANS(?)) * SIN(RADIANS(lat)))))

那个 LEAST/GREATEST 不是装饰。浮点误差会让 ACOS 的入参偶尔超出 [-1, 1],超出就返回 NULL,整个距离变成空值,排序直接乱掉。夹一刀就好了——这类 bug 只有真实数据跑起来才会遇到。


十、安全:一个交友 App 必须做的事

交友产品的安全不是"加个验证码",是每一层都要有。

10.1 请求签名

所有业务接口都要签名,防止参数被中途篡改(比如把 peer 改成别人的 ID 去读聊天记录):

1. 参数按名字字典序拼成 k1=v1&k2=v2...2. sig = md5(拼接串 + md5(secret))3. secret = 用户密码前 5 位

密钥不传网络,只用于本地计算,服务端用同样的方式算一遍比对。

10.2 密码与令牌

  • 密码用 bcrypt 加盐哈希存库,数据库泄露也拿不到明文;

  • 令牌双写 Redis + MySQL:Redis 负责快,MySQL 负责服务重启后会话不丢;

  • 有效期 7 天,过期重新登录。

10.3 内容与行为

风险措施
刷屏WS 每秒 10 条限流
单方面骚扰非好友只能发一条,对方回复才解锁
拉黑服务端校验,被拉黑方消息直接拒收
敏感内容敏感词表前置拦截,命中不调模型
成本失控每用户每日 AI 调用上限 200 次
模型胡说输出截断 + 身份露馅替换

10.4 一个容易被忽略的点:单向删除

聊天记录的"删除"该怎么做?

如果做成物理删除,你删了,对方那边也消失了——这不符合直觉,也不符合微信的行为。所以我改成了单向删除:

  • 每条消息有两个标记:from_del(发送方删了没)、to_del(接收方删了没);

  • 你清空聊天记录,只把你这一侧的标记置 1,对方完全无感;

  • 只有当两边都删过,这行数据才真正 DELETE。

实现上最难的不是数据模型,而是所有查询都要记得加过滤:聊天记录、会话列表、离线补发、未读数、语音转文字……漏一处,删掉的消息就会"诈尸"。

但有个地方我故意不过滤——防骚扰判断。因为删除只应该影响"看得见看不见",不应该把"你曾经骚扰过我"的记录洗掉,否则删一次记录就能重新获得一条消息的豁免权。


十一、怎么打包成 APK

11.1 环境

JDK 17、Gradle 8.14.3、Android SDK 33(compileSdk 33 / minSdk 24 / targetSdk 33)。

11.2 一条命令

cd bottle-androidgradle clean assembleRelease

产物在 app/build/outputs/apk/release/。

我另外写了个批处理包了一层(build-app.bat),支持 release / debug / install 三种模式,其中 install 会编译完直接推给连着的手机。

11.3 三个关键配置

① 签名

signingConfigs {    release {        storeFile file('../keystore/lovebottle.jks')        storePassword '******'        keyAlias 'lovebottle'        keyPassword '******'    }}

这是整个项目最不能丢的东西。keystore 丢了,你就再也无法给已安装的用户推送更新——他们只能卸载重装,聊天记录全丢。所以它必须离线多处备份,且绝对不能进 Git。

② 体积:按 ABI 分包

WebRTC 的原生库 libjingle_peerconnection_so 每个架构约 10MB。三个架构全打进去,安装包直接 45MB。

splits {    abi {        enable true        reset()        include 'arm64-v8a'   // 2017 年后的手机基本都是这个        universalApk false    }}

只保留 arm64 之后,45MB → 13.4MB。

③ 混淆与资源压缩

release {    minifyEnabled true    shrinkResources true}

R8 会删掉没用的代码和资源。注意 WebRTC 和 QQ SDK 的类要加 keep 规则,否则运行时才崩,而且崩得莫名其妙。

11.4 安装包信息

项值
版本1.0.0(versionCode 1)
架构arm64-v8a
大小13.4 MB
最低系统Android 7.0(API 24)
包名com.lovebottle.app

安装时如果提示"未知来源",需要在系统设置里允许;部分国产 ROM 会先做病毒扫描,点"继续安装"即可。


十二、怎么部署服务端

12.1 编译:一条交叉编译命令

CGO_ENABLED=0GOOS=linux GOARCH=amd64 \  go build -trimpath-ldflags"-s -w" \-o bottle-server ./cmd/server

换成 GOARCH=arm64 就是 ARM 服务器版本。CGO_ENABLED=0 保证编出来的是静态二进制,扔到任何 Linux 上都能跑;-s -w 去掉符号表和调试信息,体积能小三分之一。

我写了脚本一次出三个架构:amd64、arm64、armv7。

12.2 运行时依赖

组件作用缺了会怎样
MySQL 5.7全部业务数据起不来
Redis在线状态、令牌、频控起不来
ffmpeg语音消息转 PCM,送 ASR不阻塞启动,只是语音转文字不可用
Python + edge-ttsAI 语音合成不阻塞启动,语音回复降级成文字

这里有个刻意的设计:ffmpeg 缺失只在启动日志里打一行警告,不阻止服务启动。

因为"语音转文字"只是功能之一,不能因为没装 ffmpeg 就让整个站点起不来。启动时它还会自己去找:先看配置、再找 PATH、最后试几个 NAS 上的常见路径,找到就打印

ffmpeg: 使用 /usr/bin/ffmpeg

找不到就打印 [warn] 未找到 ffmpeg,然后继续跑。

12.3 systemd 托管

[Service]Type=simpleUser=bottleWorkingDirectory=/opt/bottleExecStart=/opt/bottle/bottle-server -config configs/server.jsonRestart=alwaysRestartSec=3LimitNOFILE=65535

三个参数值得解释:

  • Restart=always + RestartSec=3:崩了 3 秒后自动拉起,不用人盯着;

  • LimitNOFILE=65535:每个 WebSocket 长连接占一个文件句柄,默认 1024 连一千人就不够了;

  • Type=simple:因为程序本身就在前台跑、不 fork,写成 forking 反而会误判启动失败。

另外建议专门建一个 bottle 系统用户来跑,别用 root——服务进程只拥有 /opt/bottle 一个目录的权限,出事的爆炸半径小很多。

12.4 nginx 反代

location /ws {proxy_pass http://127.0.0.1:8080;proxy_http_version 1.1;proxy_set_header Upgrade $http_upgrade;proxy_set_header Connection "upgrade";proxy_read_timeout 3600s;}

/ws 这两行 Upgrade 头不加,WebSocket 握手会失败;proxy_read_timeout 默认 60 秒,不改的话长连接每分钟被 nginx 掐一次,表现就是"消息老是断"。

12.5 上线后自检

部署完不要只看"进程活着",按顺序验三件事:

curl http://127.0.0.1:8080/            # 返回 test_okcurl-i http://127.0.0.1:8080/ws       # 返回 401(不是 404/502)journalctl -u bottle-server -f# 看有没有 migrate / ffmpeg 报错

第一条验服务活着,第二条验 WS 路由通、鉴权生效,第三条验数据库迁移和外部依赖。


十三、我是怎么用 AI 工具做出它的

这部分可能对想动手的人更有用。

13.1 我的实际流程

不是"跟 AI 说一句"给我做个交友 App"然后就等它变出来",而是:

需求想清楚 → 先写设计文档 → 再让 AI 写代码 → 用真实数据验一遍

先写文档这一步看着慢,其实最省时间。因为文档一旦写清楚(数据表长什么样、接口参数是什么、边界怎么处理),AI 写代码的准确率会高得离谱;反过来,需求模糊的时候,AI 会非常自信地写出一堆"看起来对但完全不是你要的"东西。

这个项目光设计文档就写了 20 多份:数据模型、AI 伴侣、语音通话、视频通话、打包部署、数据库脚本……代码反而是最后才落的。

13.2 三个具体例子

例一:把一个功能从"双向删除"改成"单向删除"

我一开始把"清空聊天记录"做成了双向物理删除。后来意识到不对——微信不是这么干的。于是重新设计成 from_del / to_del 双标记。

难点不在改数据模型,而在找出所有需要加过滤的查询。我让 AI 把 6 个查询点全列出来(聊天记录、会话列表、离线补发、未读标记、语音消息、语音转写),逐个加条件,然后写了一套 30 项的端到端测试:注册两个临时账号、灌 5 条消息、分别清空,断言"我这边看不到、对方那边还在""两边都清了库里的行才真的消失"。

跑完 30 项全绿,我才敢说这个功能做完了。

例二:一个让人抓狂的报错

服务器启动报:

mysql ping: dial tcp: lookup 192.168.1.100.1: no such host

看起来是"DNS 解析不到这个 IP",但 IP 怎么会需要 DNS?

查下去发现,数据库连接串里写的是 tcp(192.168.1.100.1:3306)——IP 和端口之间敲成了点号,变成了一个五段地址。Go 的 MySQL 驱动看到地址里没有端口,就自动补了个 3306,然后把整串 192.168.1.100.1 当主机名去解析。当然解析不到。

修是改一个字符的事。但为了下次不再浪费一小时,我让 AI 在启动阶段加了一道 DSN 体检:连库之前先检查 IP 段数对不对、有没有 parseTime、库名有没有写、主机名能不能解析——把驱动的黑话翻译成人话:

MySQL 主机 "192.168.1.100.1" 不是合法 IPv4:当前 5 段,应为 4 段。常见笔误:把端口写成了 ".3306"(端口要用冒号),或 IP 末尾多打了一段。

连库超时、密码错误、库不存在、认证插件不兼容,也各有对应的中文处置提示。

例三:一次真实的"以为好了其实没好"

改完代码,测试全过,我以为完事了。结果装到手机上,登录页还显示旧的服务器地址。

原因是客户端 SharedPreferences 里存着老值,我的默认值改动只在"没有存过值"时生效。于是加了一次性迁移:用 host_migrated_v4 这个标记位,第一次启动时判断存的是不是内网调试地址(10.x / 192.168.x / 172.16-31.x / localhost),是就清掉,让它回落到新默认值。

这类问题 AI 不会主动告诉你——它只看代码,看不到"用户手机里存着旧数据"。只有真机跑一遍才会发现。

13.3 我的体会

AI 最擅长的三件事:

  1. 脚手架:16 个 Activity + 7 个 Fragment 的页面骨架、17 张表的建表语句、40 多个接口的分发,打字量最大的部分;

  2. 一致性:改了数据模型,把所有受影响的查询点、DTO、客户端解析逻辑一次改干净,人很容易漏;

  3. 解释陌生的东西:WebRTC 的 Unified Plan、MySQL 驱动的 DSN 解析、systemd 的 PATH 行为,问它比翻文档快。

AI 不擅长的三件事:

  1. 产品判断:AI 视频通话到底该不该做、删除该单向还是双向、拟人延迟该是 800ms 还是 2000ms——这些是价值判断,AI 只会给你一堆"都可以";

  2. 真机验证:手机权限弹窗、国产 ROM 的杀后台、STUN 打不通,这些只能拿设备试;

  3. 知道自己错了:它会非常自信地写出能编译但有逻辑漏洞的代码。所以测试必须自己写,而且要跑真实数据。

一句话总结:AI 把"写代码"的成本打下来了,但"想清楚要什么"和"验证做对了没有"这两件事,还是人的活。


十四、关于安装包

想试玩的话,安装包信息在上面第 11.4 节,文件名是 app-arm64-v8a-release.apk。

下载方式:公众号后台回复「心语」,获取网盘下载链接。

几点说明:

  • 这是内测版本,连接的是我自己搭的服务器,不保证 7×24 稳定;

  • 首次启动需要注册(用户名 + 密码 + 昵称 + 性别),性别注册后不能改;

  • 没有做用户内容审核的后台能力,请不要发布违规内容;

  • 定位、相机、麦克风权限都是功能需要,不给也能用(只是对应功能不可用);

  • 只提供 arm64-v8a 一个架构,2017 年后的安卓手机基本都能装。

我没有开源服务端,也没有放出编译好的服务端二进制——不是舍不得,是里面涉及真实用户的聊天数据和第三方 API 密钥,我一个人维护不了安全审计的承诺。

但技术细节我尽量在这篇文章里写透了。如果你也在做类似的东西,希望这些坑能帮你少走一点。


最后

这个项目的时间线其实很吓人:10 月 14 日做完协议逆向,10 月 16 日定稿设计文档并开始写代码,10 月 18 日功能基本跑通。前后五天。

它当然还很不完整:没有内容审核后台、没有支付、没有多实例水平扩展、AI 视频通话还没做。但它是一个能跑起来、能注册、能聊天、能打电话、能被真实用户骂"这里怎么有 bug" 的产品,而不是一个 Demo。

我一直觉得,做产品最难的不是"实现功能",是在每一个岔路口做选择,并且承担选择的后果:

  • 要不要为了"像真人"而故意让 AI 回得慢一点?

  • 要不要为了省成本而让 AI 少记一点?

  • 要不要为了"看起来功能多"而做一个半成品的 AI 视频通话?

这些问题没有标准答案,只有你愿意为什么付出代价。

如果你也对"用 AI 工具做一款自己的产品"这件事感兴趣,欢迎在评论区聊聊你想做什么。下一篇我会写:怎么把一个 WebSocket 长连接在国产手机上真正跑稳——那是这个项目里最折磨人的部分。


本文提到的所有技术实现均来自真实代码,非概念演示。

相关学习资料