第一单交付完,我等 3 个月没复购:不是交付差,是这 3 件事没做
第一个企业 AI 项目验收那天,挺顺的。甲方把验收单上的每一条都打了勾,尾款按合同在验收后十个工作日内到账。我当时想法很简单:做得这么顺,二期应该会来。
然后我等了三个月,一条消息都没有。
那段等待期和我做另外两个项目是重叠的,三个项目合计营收 50 万+,但第一个客户始终没动静。我一开始把原因归到交付质量上,回头把验收单翻出来看,每一条都是勾。后来我跟甲方对接人聊了一次,他的意思大概是:系统一直在跑,确实没出过事,但内部没人说得清它到底帮上了什么忙,所以也没人提二期。
这是我做 ToB 之后最贵的一课:**项目交付完,不等于合作开始。**验收单上的勾只证明功能在,不证明价值被看见。
后面我把三件事补上,同一个客户先追加了运维,又追加了第二个场景。今天把这三件事拆开讲,配一个我每周在跑的脚本。
一、质保期里,我给客户的不是承诺,是一张运行账
第一单的质保期是 30 天。我的做法是「有问题随时找我」。听起来负责任,实际等于把 30 天过成静默期:系统没崩,我也就没有任何主动输出。客户内部的体感是「好像没什么变化」。
改法很土:每周五下午发一段运行台账,不需要我手写,脚本跑一遍就出来。字段就五个,一周多少次调用、成功率、P95 多少毫秒、人工介入几次、兜底触发几次。
为什么要这五个?因为甲方关心的事只有三类:有没有人真的在用(调用量)、靠不靠得住(成功率 + P95)、是不是还得人盯着(人工介入 + 兜底)。五个字段正好把三类都盖住。
口径得先说死,不然周报发出去一定被追问。我用的是这一套:成功率指最终拿到了可用答案的比例,走了兜底的也算,因为兜底本身也是一种答案;兜底率和转人工率是单独统计的质量构成,不要求和成功率加起来等于一百;P95 统计的是全部有返回的调用,含兜底,谁想只看主链路,把 fallback 为 true 的记录过滤掉再算一遍就行。
日志我统一落成 JSONL,一行一次调用,脚本直接读这个文件:
go
package main
import (
"bufio"
"encoding/json"
"fmt"
"math"
"os"
"sort"
)
type Record struct {
TS int64 `json:"ts"` // Unix 秒
OK bool `json:"ok"` // 是否成功返回
Latency int `json:"latency_ms"` // 端到端耗时
Fallback bool `json:"fallback"` // 是否走了兜底
Human bool `json:"human_takeover"` // 是否转人工
}
func p95(xs []int) int {
if len(xs) == 0 {
return 0
}
cp := append([]int(nil), xs...)
sort.Ints(cp)
idx := int(math.Ceil(float64(len(cp))*0.95)) - 1
if idx < 0 {
idx = 0
}
if idx >= len(cp) {
idx = len(cp) - 1
}
return cp[idx]
}
func main() {
if len(os.Args) < 2 {
fmt.Println("usage: weekly <calls.jsonl>")
os.Exit(1)
}
f, err := os.Open(os.Args[1])
if err != nil {
fmt.Println("open failed:", err)
os.Exit(1)
}
defer f.Close()
var rs []Record
var bad int
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
for sc.Scan() {
line := sc.Bytes()
if len(line) == 0 {
continue
}
var r Record
if err := json.Unmarshal(line, &r); err != nil {
bad++
continue // 脏行跳过,不让一条坏数据把周报搞挂
}
rs = append(rs, r)
}
if err := sc.Err(); err != nil {
fmt.Println("读取失败:", err)
os.Exit(1)
}
if len(rs) == 0 {
fmt.Println("本周无调用记录,请确认采集端是否正常上报")
os.Exit(2)
}
var okCnt, fbCnt, humanCnt int
lat := make([]int, 0, len(rs))
for _, r := range rs {
if r.OK {
okCnt++
}
if r.Fallback {
fbCnt++
}
if r.Human {
humanCnt++
}
lat = append(lat, r.Latency)
}
total := len(rs)
fmt.Printf("本周调用 %d 次\n", total)
fmt.Printf("成功率 %.1f%%\n", float64(okCnt)/float64(total)*100)
fmt.Printf("P95 耗时 %d ms\n", p95(lat))
fmt.Printf("兜底触发 %d 次(%.1f%%)\n", fbCnt, float64(fbCnt)/float64(total)*100)
fmt.Printf("转人工 %d 次(%.1f%%)\n", humanCnt, float64(humanCnt)/float64(total)*100)
if bad > 0 {
fmt.Printf("跳过解析失败的行 %d 条,本周数据不完整\n", bad)
os.Exit(3)
}
}
跑出来的结果是这样的(示例数据,字段口径按你的场景调):
erlang
本周调用 1842 次
成功率 96.4%
P95 耗时 2380 ms
兜底触发 61 次(3.3%)
转人工 17 次(0.9%)
这段东西看着粗,但它是甲方内部唯一能转发给领导的证据。我后来才知道,第二笔预算能过,是靠其中一位业务同事把这段台账贴进了周会材料。
**别小看「可被转发」这件事。**你写的东西如果只能你自己看懂,它就出不了对接人那一层。
二、第一单没沉淀出底座,第二个场景就得重做一遍
第二个场景报价高,是我自己造成的。第一单做完,检索、权限、审计、兜底这四块是直接写死在业务代码里的。换一个场景,这四块要重来。
客户算过账:第一期 12 万,第二期如果还是这个量级,两年内很难批。真正让第二个场景谈下来的是我把这四块抽出来之后,第二期只算场景增量,底座复用不重复收钱。
我在验收会上补了一张表,专门讲这件事:
| 模块 | 第一单做的 | 第二个场景能不能复用 | 复用后省掉什么 |
|---|---|---|---|
| 检索 | 按业务文档切分 + 混合召回 | 能,换知识库即可 | 不用再调召回策略 |
| 权限 | 按角色过滤可见文档 | 能,换角色定义 | 不用再做一遍越权测试 |
| 审计 | 全链路调用留痕 | 能,直接接 | 不用再设计留痕字段 |
| 兜底 | 超时降级 + 转人工 | 部分能,阈值要重定 | 不用重搭降级链路 |
这张表的作用不是技术归档,是给客户一个「第二个场景会更便宜」的预期。没有它,客户默认第二期等于第一期。
三、验收会上我没说清二期怎么算钱
这一条最容易被忽略。我当时的验收会只讲「这一期做完了」,没讲「下一期怎么计价」。结果客户内部默认二期要走一次完整采购流程,流程一长,人就懒得提了。
后来我在验收材料最后固定加一页,只有三句话:
- 底座的复用清单(就是上面那张表),二期不重复计价;
- 二期按场景增量报价,给出计价口径(按场景数 / 按问答量档位,选一个,别两个都写);
- 运维单独算,按月或按季度,写清包含哪些响应(响应时限、可用率、每月变更次数上限)。
三句话写完,客户才知道「继续做下去」这件事的门槛有多低。
补上三件事之后的差别
| 没做的时候 | 补上之后 | |
|---|---|---|
| 质保期 | 有问题找我,30 天静默 | 每周一份运行台账,5 个字段 |
| 第二个场景 | 从零重做,报价接近第一单 | 复用底座,只算增量 |
| 二期计价 | 客户以为要重新招标 | 验收会上就给了口径 |
| 客户内部 | 没人说得清帮上了什么 | 台账可直接转发给领导 |
第一单 12 万,做的是企业知识问答;后面追加的运维和第二个场景,是同一条线上长出来的。三个项目三个月,合计营收 50 万+,利润率 75% 以上,成本主要压在「一个人 + 三个 AI 员工」这个结构上,不是压在报价上。
这里要说清楚一件事:我不是把 ToC 砍了去做 ToB。训练营一直在招,ToB 是另外长出来的第二条线。只是 ToB 这条线有个 ToC 没有的特点,它靠复购,不靠新客。一个客户做完不续,你就要再花一遍获客成本。
顺手说一个我踩过的坑
最早的运行台账我是手写的,写了两周就断了。原因不是懒,是「这周没什么可写的」。后来改成脚本生成,才发现没有可写的那几周,恰恰是调用量掉下去的那几周。
**能被自动生成的指标,就不要靠人记。**人会自我美化,脚本不会。
你现在手上的项目,交付完之后有没有一份能发给甲方领导的运行台账?如果没有,你打算先加哪五个字段?评论区说说,我挑典型的单独写一篇。