MoreRSS

site iconShaun修改

https://cniter.github.io/about/
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Shaun的 RSS 预览

go服务所有接口请求超时问题

2026-03-05 00:07:36

前言

  最近,新接手的一个 5 年前的 go 服务偶发性响应报错 “context deadline exceeded”,一旦有一个请求出现这个问题,所有向这个服务发送的请求都会返回超时错误,包括所有接口,且无法自行恢复,服务进程还存活但已经无法响应任何请求,彻底 hang 住了,只能重启服务。

  服务所用的框架为 Echo,数据库为 PG,连接驱动为 jackc/pgx。

问题篇

  这个问题之前也算是碰到过,不过之前的服务用的是 GoFrame V1,当时已经排查出问题是出在数据库交互逻辑,因为日志中显示请求能正常收到,唯独是查询数据库没有结果输出,当时隐约猜到是事务逻辑出了问题,在处理请求开始,就先开启了事务,这一般都是不合理的,后面用最小化原则优化了事务,在真正执行写入操作时才开启事务,写入完成就关闭,优化事务操作之后,之前的那个服务就再没出过 “context deadline exceeded” 问题,但没有继续深究原因,只能说是技术人的直觉解决了这个问题。

  现在又碰到了这个问题,趁着目前业务还不算忙,正好深入研究一下,由于有之前的经验,很快就定位了也是事务问题,代码写法和之前差不多,也是收到请求后,立马就开启了事务。起初以为是 Go 连接 PG 数据库驱动的通用问题,但一想,如果是通用问题,那这种 bug 应该早就有人反馈修复了。于是在 lib/pq 和 jackc/pgx 的 issue 上搜索,也算是运气好,正好看到个三周前的 issue,完美复现了这个超时现象,并且有大佬完美解释了这个问题。

原因篇

  issue 中示例代码已经很清楚了,这里再引用一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 连接池初始化,连接池默认配置 MaxConns = 10
func New(dburl string) (*pgxpool.Pool, error) {
dbpool, err := pgxpool.New(context.Background(), dburl)
if err != nil {
return nil, fmt.Errorf("failed to connect db: %w", err)
}
return dbpool, nil
}

// Repository 结构体
type Repository struct {
pgclient *pgxpool.Pool
}

// 测试方法,相当于一次请求
func (repo *Repository) Test(i int) (err error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

// 开启事务 - 获取一个连接
tx, beginErr := repo.pgclient.Begin(ctx)
if beginErr != nil {
return beginErr
}

defer func() {
if err != nil {
tx.Rollback(ctx)
return
}
tx.Commit(ctx)
}()

var c int
// 问题所在:使用连接池执行查询,而非事务对象
// 这会尝试获取另一个连接
err = repo.pgclient.QueryRow(ctx, "SELECT COUNT(*) FROM achievements").Scan(&c)
return err
}

  压测调用代码:

1
2
3
for i := 0; i < 10; i++ { // MaxConns = 10,恰好等于连接池大小
go repository.Test(i)
}

  表面上看代码逻辑清晰:开启事务、执行查询、提交或回滚。但深入排查就会发现,这段代码会造成死锁,问题出在查询那行代码上——使用了 repo.pgclient.QueryRow() 而非 tx.QueryRow()。示例代码的 Test 方法需要两个连接才能完成操作:1、 repo.pgclient.Begin(ctx) —— 获取第一个连接,用于开启事务;2、 repo.pgclient.QueryRow(ctx, ...) —— 尝试获取第二个连接,用于执行查询。当 10 个 goroutine 同时执行 Test 方法时:

阶段 状态 连接池剩余
初始 连接池有 10 个连接 10
阶段一 每个 goroutine 调用 Begin(),各获取 1 个连接 0
阶段二 所有 goroutine 尝试 QueryRow(),需要再获取 1 个连接 等待中...

  每个 goroutine 都持有一个连接,等待另一个连接。但没有 goroutine 能释放连接——因为释放连接需要完成查询和事务,而完成查询需要另一个连接。这就是经典的死锁场景,相互等待。

  该问题能稳定复现,主要原因也与 Go 调度器的行为有关。Go 调度器是协作式的,当一个 goroutine 执行网络 I/O(如向 PostgreSQL 发送 BEGIN)时,运行时会将其停放(park),让其他 goroutine 运行。这导致所有 goroutine 几乎同时执行到 Begin(),拿到第一个连接后同时被停放,唤醒时连接池已空,全部进入等待状态。

业务上最佳实践为:

  • 事务内的查询使用同一个连接,即使用本事务对象进行查询,不要用另外的数据库对象查询;
  • 无特殊情况,理论上应遵循事务最小化原则,只有在真正执行 DML 操作时,才开启事务,完成后,立马关闭事务,DQL 操作不应使用事务对象执行。

当然,数据库连接驱动也可以进一步优化:

  • 可以在连接池设置超时参数,如果有进程连接时间过长,则主动释放;
  • 连接等待时间过长,返回错误;
  • 主动发现 context 超时后,pgclient.QueryRow 直接返回错误。

后记

  这个问题本质上不是 pgx 的 bug,而是对事务使用方式的误解。事务与特定的数据库连接绑定,事务内的操作应该在同一个连接上执行,这不是 pgx 特有的规则,几乎所有数据库事务都是如此。在使用事务时,牢记一个原则:事务内部永远使用事务对象的方法

奥日桌面宠物开发小记

2026-03-01 21:02:19

前言

  还记得八年前通关「奥日与黑暗森林」之后,就想将奥日移植到自己的网页上,作为一个桌面宠物,当时还尝试用 unity 解包工具导出游戏中的资源,后来发现,导出也只是图片,缺少对应的数据,很难直接使用,遂作罢。

  去年偶然发现一个帖子,有人做了个 Ori 桌宠,又在 B站 搜索了下,果然发现了原作者的演示视频和更好的下载链接,基本符合 Shaun 当初想做的事,把程序包下载并解压之后,发现了所有的动画图片资源,对 Shaun 来说,开发自己的 Ori 桌宠,最难的一步算是解决了。但由于去年接触到的新事情太多,没时间弄,又拖延了一年(都是借口 (╯‵□′)╯︵┴─┴),才真正开始启动。

准备篇

  原作者总共制作了 55 个动画,图片总资源占用 200M,对于网页端,这显然不能直接使用,得先处理一下图片。前期用 AI 做了下技术调研,最终选定用 TexturePacker 打包图片资源,用 PixiJS 引擎实现动画逻辑,在打包图片之前,用 AI 先拆分了图片资源,一个动画一个独立的文件夹存储对应的动画帧。这两个关键工具对 Shaun 来说都是完全陌生的,还好 TexturePacker 操作很简单,PixiJS 有 AI 辅助开发功能。全部动画帧图片打包之后,占用空间就相对比较小了,再用 TinyPNG 压缩了一下,在网页端就算能用了。

实现篇

  整体桌宠框架由 AI 迭代完成,Shaun 只是提出了自己的想法,所有动画逻辑支持配置化,一个原始动画可能产生多个动作(eg:翻转,旋转都是新的动作),AI 在一步步的追问中最终使用 ECS架构 完成了这个桌宠主体程序。Shaun 顺着这个基础架构针对每一个动画进行调试,设计实现了边界碰撞,点击,延时/随机动画序列等逻辑,这些逻辑设计上花的时间是最多的,毕竟全凭个人喜好,随意发挥,AI 目前还无法做这种决策。慢慢悠悠断断续续的花了一个月的休息时间,最终好歹在春节前完成了,算是送给自己的新年礼物。

最终成果为Ori 桌面宠物

感想篇

  从拿到动画图片资源,到正式实现,拖延了一年,这一年,AI 的变化确实巨大,一年前让 AI 实现这个应该不会有这样顺利,拖延也算是有拖延的好处 :P。当然也碰到了很多必须要人工介入的问题,比如 PixiJS 版本不兼容问题,明明指定了版本,但有时生成的代码会冒出老版本的 api,导致报错,因为莫名其妙的原因还无法自我纠正,只能人工介入,重开对话,甚至需要人工调试,查阅文档,手动解决该问题。

  在 AI 的使用中,也发现了,AI 与 AI 之间,差距也不小,这个 AI 不行,换个 AI 可能就解决了。多轮对话错误之后,最好是新开对话。语言描述能力对现在的 AI 非常重要,可预见的未来,干活模式会和现在完全不一样。

后记

  拥抱 AI,拥抱未来。

jackc/pgx 查询错误处理避坑

2025-03-22 23:52:12

前言

  最近,一个数据查询服务被业务方反馈拿不到数据,但接口响应是成功的,不报错,仔细排查后发现数据查询库用的是 pgx,但 pgx 返回的错误未被处理,导致服务接口没有响应错误。

  在后续的排查过程中,发现这其实不算是 pgx 的问题,而是 database/sql 中的坑,所有涉及用 database/sql 查询的都需要显式处理 rows.Err()。

问题篇

  服务所用 pgx 版本为 4.10.1。查询函数主要用的是 QueryRow(返回一条数据) 和 Query(返回多条数据),更近一步的测试中(人为制造查询错误,eg:锁表)发现,调用 QueryRow 函数的接口,如果发生查询错误的问题,服务接口会正常响应错误。深入 pgx 源码发现,QueryRow 本质是对 Query 的进一步封装,对应的 Scan 函数源码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
// QueryRow 返回的 connRow 对应继承自 Query 返回的 connRows 对象
type connRow connRows
func (r *connRow) Scan(dest ...interface{}) (err error) {
// 显式类型转换
rows := (*connRows)(r)

if rows.Err() != nil {
return rows.Err()
}

if !rows.Next() {
if rows.Err() == nil {
return ErrNoRows
}
return rows.Err()
}

rows.Scan(dest...)
rows.Close()
// 关键的错误
return rows.Err()
}

func (rows *connRows) Scan(dest ...interface{}) error {
ci := rows.connInfo
fieldDescriptions := rows.FieldDescriptions()
values := rows.values

if len(fieldDescriptions) != len(values) {
err := errors.Errorf("number of field descriptions must equal number of values, got %d and %d", len(fieldDescriptions), len(values))
rows.fatal(err)
return err
}
if len(fieldDescriptions) != len(dest) {
err := errors.Errorf("number of field descriptions must equal number of destinations, got %d and %d", len(fieldDescriptions), len(dest))
rows.fatal(err)
return err
}

if rows.scanPlans == nil {
rows.scanPlans = make([]pgtype.ScanPlan, len(values))
for i := range dest {
rows.scanPlans[i] = ci.PlanScan(fieldDescriptions[i].DataTypeOID, fieldDescriptions[i].Format, dest[i])
}
}

for i, dst := range dest {
if dst == nil {
continue
}

err := rows.scanPlans[i].Scan(ci, fieldDescriptions[i].DataTypeOID, fieldDescriptions[i].Format, values[i], dst)
if err != nil {
err = scanArgError{col: i, err: err}
rows.fatal(err)
return err
}
}

// 由于返回多行数据,需要多次 Scan, 所以不能返回 rows.Err()
return nil
}

// 有些数据库查询错误,只有在 Close 之后,再调用 rows.Err() 捕获
func (rows *connRows) Close() {
if rows.closed {
return
}

rows.closed = true

if rows.resultReader != nil {
var closeErr error
rows.commandTag, closeErr = rows.resultReader.Close()
if rows.err == nil {
// 赋值错误
rows.err = closeErr
}
}

if rows.multiResultReader != nil {
closeErr := rows.multiResultReader.Close()
if rows.err == nil {
// 赋值错误
rows.err = closeErr
}
}

if rows.logger != nil {
if rows.err == nil {
if rows.logger.shouldLog(LogLevelInfo) {
endTime := time.Now()
rows.logger.log(rows.ctx, LogLevelInfo, "Query", map[string]interface{}{"sql": rows.sql, "args": logQueryArgs(rows.args), "time": endTime.Sub(rows.startTime), "rowCount": rows.rowCount})
}
} else {
if rows.logger.shouldLog(LogLevelError) {
rows.logger.log(rows.ctx, LogLevelError, "Query", map[string]interface{}{"err": rows.err, "sql": rows.sql, "args": logQueryArgs(rows.args)})
}
if rows.err != nil && rows.conn.stmtcache != nil {
rows.conn.stmtcache.StatementErrored(rows.sql, rows.err)
}
}
}
}

  从源码中可看出 QueryRow 的 Scan 函数有一系列的错误处理,而 Query 对应的 Scan 是更底层的函数,返回的仅是 Scan 过程中的错误,其他的错误需要在业务上层处理。Close 函数同样可能会出现错误,需要调用 rows.Err() 主动检查错误(这一步至关重要)。对于 Close 报的错,可以这样处理:

1
2
3
4
5
6
7
8
9
10
11
12
var err error
defer func() {
rows.Close()
closeErr := rows.Err()
if err != nil {
if closeErr != nil {
log.Printf("failed to close rows: %v", err)
}
return
}
err = closeErr
}

  数据库执行 sql 失败的错误(eg:canceling statement due to conflict with recovery),在 Close 后才会暴露出来,所以不处理这个错误,就不会返回错误,但数据又查不到,服务接口也表现为响应成功,导致上层业务误认为数据库里还真没数据。

  最好的方式还是避免每次都手动 Scan,pgx 其实还提供了更上层的函数 QueryFunc,该函数封装了大部分错误处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func (c *Conn) QueryFunc(ctx context.Context, sql string, args []interface{}, scans []interface{}, f func(QueryFuncRow) error) (pgconn.CommandTag, error) {
rows, err := c.Query(ctx, sql, args...)
if err != nil {
return nil, err
}
defer rows.Close()

// 最后一次 Next() 会自动调用 Close()
for rows.Next() {
err = rows.Scan(scans...)
if err != nil {
return nil, err
}

err = f(rows)
if err != nil {
return nil, err
}
}

if err := rows.Err(); err != nil {
return nil, err
}

return rows.CommandTag(), nil
}

不过 QueryFunc 函数在新版本中(5.7.2)已被 ForEachRow 替代:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func ForEachRow(rows Rows, scans []any, fn func() error) (pgconn.CommandTag, error) {
defer rows.Close()

for rows.Next() {
err := rows.Scan(scans...)
if err != nil {
return pgconn.CommandTag{}, err
}

err = fn()
if err != nil {
return pgconn.CommandTag{}, err
}
}

if err := rows.Err(); err != nil {
return pgconn.CommandTag{}, err
}

return rows.CommandTag(), nil
}

而 ForEachRow 的使用示例可以看这个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func (c *Conn) getCompositeFields(ctx context.Context, oid uint32) ([]pgtype.CompositeCodecField, error) {
var typrelid uint32

err := c.QueryRow(ctx, "select typrelid from pg_type where oid=$1", oid).Scan(&typrelid)
if err != nil {
return nil, err
}

var fields []pgtype.CompositeCodecField
var fieldName string
var fieldOID uint32
rows, _ := c.Query(ctx, `select attname, atttypid
from pg_attribute
where attrelid=$1
and not attisdropped
and attnum > 0
order by attnum`,
typrelid,
)
// 这里是示例
_, err = ForEachRow(rows, []any{&fieldName, &fieldOID}, func() error {
dt, ok := c.TypeMap().TypeForOID(fieldOID)
if !ok {
return fmt.Errorf("unknown composite type field OID: %v", fieldOID)
}
fields = append(fields, pgtype.CompositeCodecField{Name: fieldName, Type: dt})
return nil
})
if err != nil {
return nil, err
}

return fields, nil
}

后记

  对于一个不熟悉的底层库,最好的学习方式还是看它的示例代码,库的开发者很难知道用户会踩哪些坑,文档中自然不会有,毕竟当局者迷。只从文档出发,很容易陷进未知的坑里,甚至掉坑里都不知道,业务出问题后,花费大代价排查之后,才知道掉坑里了。陌生的开源库在使用的时候还是先全库 clone 下来,用 api 的时候,就去源码里搜一下,看看开发者写的示例(不管是测试,还是其他地方的调用),当然现在也可以让 AI 先写,人只要再核实一下文档和源码,能节省很多学习的功夫。

天文通预测气象奇观小记

2025-01-26 23:26:15

前言

  出去玩看风景很大程度上受天气的影响,所以一般会使用「莉景天气」和「天文通」提前看下天气预报。莉景天气对于各种气象奇观都有明确的预报概率,但需收费;天文通免费,但只能根据其上的数据自行推断,Shaun 根据网上的一些资料和个人见识总结了一些奇观的出现条件。天气数据都有个准确率的问题,尤其是在高海拔地区,气象多变,数据仅供参考,所以下面列出的条件也都仅供参考,气象奇观出现是幸运,不出现才是正常的。

奇观篇

  • 朝霞/晚霞:高中层云量在 30%~70%,空气湿度低,微风,空气质量好,能见度高。一般在日出之前与日落之后,太阳在地平线后 15~20 分钟之内的余辉最好看。预测火烧云可以看「火烧云分析与记录
  • 云海:低空云量越多越好。
  • 佛光:天气为晴天,低空云量多,气温低,湿度大。
  • 雾凇:气温低于露点,湿度在80%以上,微风,昼夜温差大。
  • 日照金山:天气为晴天,云量低于 10%,尤其是高中层云量。

附录

徒步经验篇

  • 同样的动作,高海拔相比低海拔消耗的能量更多,所以高海拔徒步时,尤其需要注意自己的节奏,切忌急上急下,当心率变快,或者呼吸不畅时,应该及时停下休息,同时补充能量(喝含糖饮料,吃东西),等呼吸和心率正常时再行走;
  • 高海拔上陡坡时,宁可慢一点,一步只走半只脚的距离,必须要高抬脚上坡的时候,上完之后可休息 10s;
  • 高海拔尽量不洗澡,不泡脚,长时间的泡热水容易高反,当然如果想测试洗澡后会不会高反,可以在行程的最后一天试试;
  • 在无人区徒步,最好记录自己的行驶轨迹(最差也得记录起终点的 GPS 坐标),在开阔的地方,就算没有网络信号,但 GPS 一般还是有的;
  • 徒步完之后,最好拉伸一下,主要是大腿前后和小腿前后四块肌肉群,爬升大的话,需要拉伸大腿前侧肌肉(用手向后抬起小腿,尽量抬高小腿,脚后跟贴近臀部)和小腿后侧肌肉(整条腿伸直,抬起脚尖),下降多的话,需要拉伸小腿前侧肌肉(腿略微弯曲,脚尖尽量向后)。在拉伸时需要注意姿势,找好支撑,别扭伤了
  • 万能腿部拉伸动作

LLM 本地部署运行初体验

2025-01-12 16:47:28

前言

  最近看到这么一个工具——ollama-ocr,利用本地大模型直接进行 OCR,试用了一下,感觉效果还不错,联想之前看到的一个项目——PDFMathTranslate,感觉本地大模型已经非常成熟了,部署使用也越来越简单了。

如果需要提供 LLM 服务,还是用 vLLM 部署相对合适。

配置篇

  硬件:M3 MBP,10 核 GPU(Metal 3),16G 内存

  系统&软件:macOS Sonoma 14.5Ollama 0.5.4,AnythingLLM 1.7.2;

体验篇

ollama 篇

  第一次启动 Ollama 时,会出现安装「command line」的引导界面,输入电脑用户密码安装即可,安装命令行之后,可以直接点击「Finish」,之后通过终端命令 ollama 操作 Ollama,毕竟 Ollama 没有提供 UI 界面,当然也有很多三方的界面

  之后在终端中直接运行 ollama run llama3.2:3b,等待模型拉取完成后,即可直接与模型对话。之后可以在 Ollama 官网 Models 搜索尝试更多模型。

命令解析:

  • ollama pull [model:tag]:拉取模型;
  • ollama run [model:tag]:运行拉取的模型,若没有,会自动拉取之后运行;
  • ollama list:查看全部已拉取的模型;
  • ollama show [model:tag]:显示模型信息;
  • ollama rm [model:tag]:删除已拉取的模型;
  • ollama ps:查看正在运行的模型;
  • ollama stop [model:tag]:停止正在运行的模型;

  Mac 中修改全局环境变量可通过 launchctl setenv 命令,eg: launchctl setenv OLLAMA_ORIGINS "*",允许 ollama 请求跨域。

  Mac 中 ollama 拉取的模型文件默认放在 ~/.ollama/models 目录中,可通过修改 OLLAMA_MODELS 环境变量更改模型安装目录。

  Ollama 默认服务地址端口是:127.0.0.1:11434,Mac 中查看进程监听的端口号命令为 lsof -nP -p <pid>。可通过修改 OLLAMA_HOST 环境变量更改默认端口,eg:launchctl setenv OLLAMA_HOST "0.0.0.0:6006"

  默认情况下,运行模型后,如果 5 分钟未与模型进行交互,将会自动停止该模型。

  Mac 启动 Ollama 后,会在菜单栏上出现一个羊驼图标,但有时这个图标会被“刘海”挡住,导致无法退出 Ollama,这时可以使用 osascript -e 'tell app "Ollama" to quit' 命令退出 Ollama。

  对于开发者,若需要更改默认端口,需修改环境变量:export OLLAMA_HOST=0.0.0.0:6006(如此可将 ollama 的默认端口修改为 6006),之后通过 ollama serve 命令启动 Ollama,通过该命令启动的不会在菜单栏上出现羊驼图标。若出现跨域问题,同样需要修改环境变量:export OLLAMA_ORIGINS="*"

AnythingLLM 篇

  AnythingLLM 有两种安装模式,一种是桌面版,一种是 Docker 版,桌面版只能本地使用,Docker 版相当于是服务版,支持多用户云端使用。本次选用的桌面版,基本的 RAG 功能也都有。Docker vs Desktop Version

  第一次启动 AnythingLLM 时,有一些设置引导,设置「LLM 偏好」时选择 Ollama,其他的都默认即可。创建工作空间之后就可以上传本地文件,建立自己的知识库。

  Shaun 在使用中感觉,AnythingLLM 响应还是比较慢,分析/提炼/归纳/总结本地文档的速度有限。可能是机器配置还是有点低了。

PDFMathTranslate 篇

  命令行使用:

1
2
3
4
5
# 用 ollama 将本地文件 Black Hat Rust.pdf 从英文翻译为中文
pdf2zh Black\ Hat\ Rust.pdf -s ollama -li en -lo zh

# 启动 pdf2zh 网页,免去命令行使用 Web 页面设置翻译参数
pdf2zh -i

  出现 NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3' 警告时可忽略,不影响使用。

  如果出现 huggingface_hub.errors.LocalEntryNotFoundError 错误,需要配置 hugging_face 国内镜像:

1
2
pip3 install -U huggingface_hub hf_transfer -i https://pypi.tuna.tsinghua.edu.cn/simple
export HF_ENDPOINT=https://hf-mirror.com

  同时下载模型到本地(需要具体看报错的是哪个模型,这里是wybxc/DocLayout-YOLO-DocStructBench-onnx):

1
huggingface-cli download --resume-download wybxc/DocLayout-YOLO-DocStructBench-onnx --local-dir .

  选择本地ollama模型作为翻译服务的话,需要配置环境变量:

1
2
3
4
# 本地ollama服务地址
export OLLAMA_HOST=http://127.0.0.1:11434
# 选用phi4模型翻译
export OLLAMA_MODEL=phi4

  有个浏览器插件「沉浸式翻译 - Immersive Translate」同样是个比较好用的翻译工具。不过对于 PDF 文件,都有可能出现译文重叠的现象,需要二次编辑一下,或者将 PDF 格式转换为其他格式(eg:html,epub 等,相关 issue)。

VSC 辅助编程插件篇

Continue

   Continue 插件可结合本地的 ollama 使用 qwen2.5-coder:7b 模型,可辅助读/写代码,需打开以下设置:

  • Continue: Enable Quick Actions
  • Continue: Enable Tab Autocomplete
  • Continue: Show Inline Tip

模型设置添加:

1
2
3
4
5
{
"model": "qwen2.5-coder:7b",
"title": "ollama-qwen2.5-coder",
"provider": "ollama"
}

自动补全设置中 apiKey 保持为空字符串就行。

若需要使用远程部署的 ollama 服务,可以新增参数 "apiBase": "http://<my endpoint>:11434"

Cline

   Cline 插件同样可结合本地的 ollama 使用,可辅助 CR 以及自动化优化修改代码。如果使用 vLLM 部署的 AI 服务,API Provider 选择 OpenAI Compatible,Bsae URL 填 http://ip:port/v1,API Key 随便填就行(eg: ollama),Model ID 则是模型名称(eg: deepseek-r1:14b)。


20250207 更新:

  • 英译中模型推荐使用 qwen2.5:14b;
  • 中文问答聊天模型推荐使用 deepseek-r1:14b,模型会输出详尽的思考过程;

后记

  本地部署 LLM 的好处在于无数据泄漏问题,对于个人使用而言,轻量级的模型也差不多够用了,但即使已经轻量化了,本地运行大模型还是有点吃力,在 Shaun 的电脑上运行 phi4:14b 略显勉强(8 tok/s)。Mac 的内存和显存是共享的,后续如果买新的,有部署 LLM 的需求,最好把内存拉满,由于模型文件也相对较大,有条件的可以把 SSD 也拉满。希望后续大模型的推理能够进一步轻量化,效果也更好,真正实现人人都能使用。

  自 GPT-3 出现以来,也就短短 4 年不到,从大规模的高性能 GPU 集群到单机部署,从胡言乱语到精准命中,各行各业都迎来了 LLM 的冲击,在可预见的未来,LLM 将深刻影响到每一个人,这种影响无关好坏,单纯只是时代的浪潮,LLM 将和操作系统,数据库一样,成为整个 IT 行业的基础设施,就 Shaun 而言,应该很难亲自动手去开发优化 LLM,能做的也就是尽可能的熟练使用。


20260121更新:

  • OpenCode 是真牛逼,需求写好,摸会儿鱼,代码就写完了,编码替身。

参考资料

1、基于Ollama+AnythingLLM搭建本地私有知识库系统

2、ollama搭建本地个人知识库

工作中特殊场景下的黑魔法

2024-12-22 22:26:19

前言

  工作中偶尔会遇到一些特殊需求需要解决,这里记录一下。

需求篇

Mac 修改文件创建时间和修改时间

  使用 setfile 命令:

修改创建日期:setfile -d "mm/dd/yy hh:mm:ss" filename

修改修改日期:setfile -m "mm/dd/yy hh:mm:ss" filename

同时修改 xxx.txt 文件两个时间为 2023-07-27 01:23:53:

setfile -d "07/27/2023 01:23:53" -m "07/27/2023 01:23:53" ./xxx.txt

Excel 修改创建时间

  word 和 excel 本质上都是 zip 文件,可利用 openpyxl 修改 xlsx 文件元信息创建时间。对于 xls 文件,若文件有密码,需先去除密码,再将 xls 转换为 xlsx 文件,之后使用 openpyxl 修改时间。具体步骤如下:

  1. 用 AppleScript 将 xls 转换为 xlsx

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    def run_applescript(script):
    """运行 AppleScript 脚本"""
    subprocess.run(["osascript", "-e", script])

    def xls_to_xlsx(file_path="./xxx.xls"):
    """使用 AppleScript 修改 Excel 文件元数据"""
    applescript = f'''
    tell application "Microsoft Excel"
    -- 打开 .xls 文件
    set inputFile to "{file_path}" -- 修改为你的文件路径
    open inputFile

    -- 获取当前工作簿
    set wb to active workbook

    -- 定义输出文件路径
    set outputFile to "{file_path}x" -- 修改为你想保存的文件路径

    -- 保存为 .xlsx 格式
    save workbook as wb filename outputFile file format Excel XML file format

    -- 关闭工作簿
    close wb saving no
    end tell
    '''
    run_applescript(applescript)
  2. 修改 xlsx 文件创建时间

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    import openpyxl

    def modify_excel_metadata(file_path = "./xxx.xlsx"):
    # 打开 Excel 文件
    wb = openpyxl.load_workbook(file_path)

    # 获取元数据(properties)
    # properties = wb.properties
    # print(properties.__dict__)
    dt = datetime.strptime("2023-01-07 14:00:45", "%Y-%m-%d %H:%M:%S")
    dt -= timedelta(hours=8)
    wb.properties.creator = ""
    wb.properties.modified = dt
    wb.properties.created = dt
    wb.save("./xxx_tmp.xlsx")
  3. 将 xlsx 转换为 xls 文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    def xlsx_to_xls(file_path="./xxx_tmp.xlsx"):
    """使用 AppleScript 修改 Excel 文件元数据"""
    applescript = f'''
    tell application "Microsoft Excel"
    -- 打开 .xls 文件
    set inputFile to "{file_path}" -- 修改为你的文件路径
    open inputFile

    -- 获取当前工作簿
    set wb to active workbook

    -- 定义输出文件路径
    set xlsFilePath to (inputFile as text)
    set xlsFilePath to text 1 thru -6 of xlsFilePath -- 去掉 ".xlsx"
    set xlsFilePath to xlsFilePath & ".xls"
    # log xlsFilePath

    -- 保存为 .xls 格式
    save wb in xlsFilePath
    # save workbook as wb filename xlsFilePath file format Excel98to2004 file format with overwrite

    -- 关闭工作簿
    close wb saving yes
    end tell
    '''
    run_applescript(applescript)

JPG 修改创建时间

  利用 pillow 和 piexif 修改 jpg 文件 exif 信息时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from PIL import Image
import piexif

def modify_jpg_exif(img_file="./xxx.jpg", time_str = "2023:01:07 14:00:45"):
im = Image.open(img_file)
if "exif" not in im.info:
return
exif_dict = piexif.load(im.info["exif"])

# for ifd in ("0th", "Exif", "GPS", "1st"):
# for tag in exif_dict[ifd]:
# print(ifd, tag, piexif.TAGS[ifd][tag], exif_dict[ifd][tag])

del exif_dict["1st"]
del exif_dict["thumbnail"]

exif_dict["0th"][piexif.ImageIFD.DateTime] = time_str.encode()
exif_dict["Exif"][piexif.ExifIFD.DateTimeOriginal] = time_str.encode()
exif_dict["Exif"][piexif.ExifIFD.DateTimeDigitized] = time_str.encode()
exif_bytes = piexif.dump(exif_dict)
im.save("./xxx_m.jpg", exif=exif_bytes, quality='keep', subsampling='keep')

  Pillow 保存 jpg 图片默认会同时保存 JFIF 和 EXIF 头,若需要去掉 JFIF 头,需修改 Pillow JpegEncode.c 文件源码:

1
2
3
4
5
6
if (context->xdpi > 0 && context->ydpi > 0) {
context->cinfo.write_JFIF_header = TRUE;
context->cinfo.density_unit = 1; /* dots per inch */
context->cinfo.X_density = context->xdpi;
context->cinfo.Y_density = context->ydpi;
}

修改为:

1
context->cinfo.write_JFIF_header = FALSE;

之后执行:python3 -m pip -v install . 从本地源码安装 Pillow。

PDF 修改创建时间

  使用 pikepdf 修改 pdf 文件元信息时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import pikepdf

def modify_pdf_metadata(file_path="./xxx.pdf", time_str = "20241203140045+08'00'"):
# 打开 PDF 文件
with pikepdf.open(file_path, allow_overwriting_input=True) as pdf:
## 获取 PDF 的元数据
# metadata = pdf.docinfo
# for key, value in metadata.items():
# print(f'{key}: {value}')

# 修改元数据
pdf.docinfo["/CreationDate"] = time_str
pdf.docinfo["/ModDate"] = time_str
# 保存文件
pdf.save()