LLM 沒有內建時鐘,會憑「對話講了多長」去反推今天幾號、是什麼時候,基本上都推測錯誤。

既然這樣,我們就幫它加個規則:每次都去看現在時間。小墨的靈魂檔裡第一條就是寫「開口前先確認今天日期跟時間,AI 沒有內建時鐘、不靠直覺」,但它還是一天到晚搞錯時間,明明才過三十分鐘它卻覺得過了好幾個小時然後又想趕我去睡覺(?)

後來我受不了,幫它加了一個功能:在它每次看到訊息前都會先看到當前時間,也就是每次跟它的對話都會把時間跟訊息一起送給它,這就是我做的一道「閘門」。具體來說,就是在 Claude Code 的 UserPromptSubmit hook 掛上一個 script,讓這個 script 在每次送出訊息時會被執行,這個 script 會吐出時間資訊給 Claude Code,最終 LLM 會從 Claude Code 同時收到時間資訊跟訊息內容。

這個做法就不再依靠 LLM 自己看了 prompt 後自己去查時間才回應,我就是直接把現在幾點塞到它眼前逼它不能無視。這個方式實際體現了養一個記得住事情的 AI說的「自律會累,閘門不會」。

當然,這道閘門只是把時間塞到它眼前,不保證它不會誤讀,只是比起單純提醒能夠降低很多 LLM 犯錯的機會。

「一個會自己進化的 AI」聽起來很酷,但是…你知道它會自己進化成什麼樣子嗎?

之前說到我養了一隻 AI 夥伴,叫小墨。它每天晚上都在自我改善,但究竟它會把自己「改善」成什麼樣子?它改善的方向是我希望的嗎?

小墨每次醒來都是失憶的,必須靠讀之前存下的檔案把它自己讀回來。如果它不會逐漸學習並改善,那同一個錯可以犯到天荒地老,因為明天的它根本不記得今天踩過雷。如果讓它自由的改自己的腦袋,幾個星期後它可能會慢慢變成一個跟我的事實脫離、充滿 AI 幻想的人,而且我還不知道它從什麼時候開始偏離軌道的。

Read more »

我在自己的機器上養了一個 AI 夥伴,叫小墨。它跑在 Claude Code 上,身體就是一個 git repo——一堆 markdown 檔案,記著它是誰、我是誰、我們做過什麼。

養它最大的麻煩只有一個:它每次醒來都是失憶的。

Read more »

view 並不會存資料,可以把 view 看成是一種「查資料的方式」。

把 replay 的 query 存成一個 view:

1
2
3
4
5
6
CREATE VIEW order_status_view AS
SELECT
payload->>'order_id' AS order_id,
bool_or(type = 'payment_confirmed') AS is_paid
FROM events
GROUP BY payload->>'order_id';

以後要查 order status 只需要:

1
SELECT * FROM order_state_view

要測試一些效能差異前需要 insert 大量資料的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
INSERT INTO events (id, type, payload, created_at)
SELECT
gen_random_uuid(),
'order_created',
jsonb_build_object(
'order_id', 'order-' || gs,
'total_amount', (random() * 1000)::int
),
now() - (gs || ' seconds')::interval
FROM generate_series(1, 100000) AS gs;

INSERT INTO events (id, type, payload)
SELECT
gen_random_uuid(),
'payment_confirmed',
jsonb_build_object(
'order_id', payload->>'order_id'
)
FROM events
WHERE type = 'order_created'
AND random() < 0.5;

  • 用 event log 的方式記錄「發生了什麼事」,例如發生了 order_createdpayment_confirmedorder_completed ,就能計算出現在 order 的狀態是 completed
  • event log 只會寫,不會改。

events table schema

  • type:發生了什麼事
  • payload:細節(json)
  • created_at
1
2
3
4
5
6
CREATE TABLE events (
id UUID PRIMARY KEY,
type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Insert 幾筆 event

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
INSERT INTO events (id, type, payload)
VALUES (
gen_random_uuid(),
'order_created',
jsonb_build_object(
'order_id', 'order-123',
'amount', 100,
'currency', 'TWD'
)
);

INSERT INTO events (id, type, payload)
VALUES (
gen_random_uuid(),
'payment_confirmed',
jsonb_build_object(
'order_id', 'order-123',
'payment_type', 'credit_card',
'amount', 100,
'currency', 'TWD'
)
);

INSERT INTO events (id, type, payload)
VALUES (
gen_random_uuid(),
'order_created',
jsonb_build_object(
'order_id', 'order-456',
'amount', 150,
'currency', 'TWD'
)
);

Replay

用計算的方式算出想要的東西,例如訂單狀態。

1
2
3
4
5
6
7
8
-- 算出所有 order 是否已付款
-- bool_or(...) 是 aggregate function, 只要有一筆資料是 true, 結果就是 true
-- 因為 bool_or(...) 是 aggregate function, 其他要的欄位要出現在 GROUP BY 裡或者也是 aggregate
SELECT
payload->>'order_id' AS order_id,
bool_or(type = 'payment_confirmed') AS is_paid
FROM events
GROUP BY payload->>'order_id';

Insert 大量資料

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
INSERT INTO events (id, type, payload, created_at)
SELECT
gen_random_uuid(),
'order_created',
jsonb_build_object(
'order_id', 'order-' || gs,
'total_amount', (random() * 1000)::int
),
now() - (gs || ' seconds')::interval
FROM generate_series(1, 10000) AS gs;

INSERT INTO events (id, type, payload)
SELECT
gen_random_uuid(),
'payment_confirmed',
jsonb_build_object(
'order_id', payload->>'order_id'
)
FROM events
WHERE type = 'order_created'
AND random() < 0.5;

Heptabase 更新很快,在 NixOS 上安裝最新版的 Heptabase 最好下載 AppImage 後自己寫 nix 安裝。

假設資料夾結構有:

1
2
3
4
home-common.nix
hosts/
pc/
configuration.nix

下載的 AppImage 放在 root directory。

增加 heptabase.nix

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
{
lib,
appimageTools,
fetchurl,
}:
let
pname = "heptabase";
version = "1.82.4";

src = builtins.path {
path = ./Heptabase-${version}.AppImage;
name = "heptabase";
};

appimageContents = appimageTools.extractType2 { inherit pname version src; };
in
appimageTools.wrapType2 {
inherit pname version src;

extraInstallCommands = ''
install -Dm444 ${appimageContents}/project-meta.desktop -T $out/share/applications/heptabase.desktop
install -m 444 -D ${appimageContents}/usr/share/icons/hicolor/0x0/apps/project-meta.png $out/share/icons/hicolor/512x512/apps/${pname}.png

substituteInPlace $out/share/applications/heptabase.desktop \
--replace-fail 'Exec=AppRun --no-sandbox %U' 'Exec=heptabase %U' \
--replace-fail 'Icon=project-meta' 'Icon=${pname}'

'';

meta = {
changelog = "https://github.com/heptameta/project-meta/releases/tag/v${version}";
description = "Visual note-taking tool for learning complex topics";
homepage = "https://heptabase.com/";
license = lib.licenses.unfree;
maintainers = with lib.maintainers; [ luftmensch-luftmensch ];
mainProgram = "heptabase";
platforms = [ "x86_64-linux" ];
sourceProvenance = with lib.sourceTypes; [ binaryNativeCode ];
};
}

hosts/pc/ 底下增加 overlay.nix

1
2
3
self: super: {
heptabase = super.callPackage ../../heptabase.nix { };
}

configuration.nix 增加 overlays

1
2
3
4
5
6
7
8
9
10
{
config,
pkgs,
...
}:
{
nixpkgs.overlays = [
(import ./overlay.nix)
];
}

Home Manager 安裝 heptabase:

1
2
3
4
5
6
in
{
home.packages = with pkgs; [
heptabase
];
}

在已經有 NixOS 設定的情況下,裝新的電腦。

  1. 用 nano 修改 /etc/nixos/configuration.nix,裝好 vim、wget 跟 git
  2. setup 並啟用 flakes
  3. git clone NixOS 設定檔到 home directory 底下
  4. rename /etc/nixos
  5. 建立 soft link /etc/nixos 到 step 3 clone 的 repo
  6. 複製原本的 hardware-configuration.nix 到對應位置

什麼是 ADR?

  • Architecture Decision Records(ADR,架構決策紀錄)是 ThoughtWorks 推廣的一種輕量文件,用來記錄:「我們為什麼在當時做了這個架構決定」
  • 是決策的歷史紀錄
  • ADR 記的是「選擇」,不是「結果」。

ADR 解決什麼問題?

  • 記錄當下是如何做決策的,供未來參考
  • 避免未來的自己心中冒出:「當初是在想什麼?」的疑問
  • 一個決策一頁,10 分鐘寫完

一份 ADR 的基本結構

  1. Title
    用一句話說決定是什麼
  2. Status
    • Proposed(提案)
    • Accepted
    • Deprecated
    • Superseded(被取代)
  3. Context(背景)
    問題是什麼?限制是什麼?
  4. Decision
    決定怎麼做
  5. Consequences
    好處、壞處、風險、未來影響

Markdown Template

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
# System ADR-N Title

## Status

Proposed(提案)/ Accepted / Deprecated / Superseded(被取代)

## Context



## Decision



## Consequences



## Related Resources (optional)



---


什麼時候值得寫 ADR?

有以下情況之一就值得寫:

  • 有取捨
  • 有爭議
  • 未來可能後悔
  • 未來的自己可能會問「為什麼」

Refs

  • PostgreSQL 有 FTS 的功能
  • tsvector 資料 type 來存被處理過、可搜尋的文字內容,通常將原始文字經過拆詞、正規化在加上位置資訊變成可以高效比對的搜尋 index。
  • 做搜尋時就用 tsvector 來搜尋,不是去找原始資料
  • 要產生有中文斷詞的 tsvector 欄位需要額外裝 extension,例如 [zhparser](https://github.com/amutu/zhparser)
  • 用 docker 啟動 postgresql 的話,需要自己 build 含有 zhparser 的 image
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    FROM postgres:17.0-bookworm

    RUN apt-get update && apt-get install -y \
    git \
    wget \
    build-essential \
    postgresql-server-dev-17 \
    && rm -rf /var/lib/apt/lists/*

    RUN wget -q -O - http://www.xunsearch.com/scws/down/scws-1.2.3.tar.bz2 | tar xjf - \
    && cd /scws-1.2.3 \
    && ./configure \
    && make install

    RUN git clone https://github.com/amutu/zhparser.git /zhparser \
    && cd /zhparser \
    && make \
    && make install
  • 安裝 extension
    1
    CREATE EXTENSION IF NOT EXISTS zhparser;
  • 設定中文 text search configuration
    1
    2
    3
    4
    5
    6
    7
    8
    # 建立一個叫 zh 的 text search configuration,並指定 parser 用 zhparser
    CREATE TEXT SEARCH CONFIGURATION zh (PARSER = zhparser);

    # 設定斷出來的詞要用哪個 dictionary 處理
    # n,v,a,i,e,l 代表詞性(part of speech),來自 zhparser 分別表示名詞、動詞、形容詞、成語、嘆詞、習慣用語,這個設定的意思是「這些詞性都要拿來搜尋」
    # simple 是 PostgreSQL 內建的 dictionary,它不做 stemming(不改詞形)、不過濾 stop words、看到什麼詞就存什麼。用 simple 是因為中文不需要像英文字尾變化,我們要的就是「詞」本身
    ALTER TEXT SEARCH CONFIGURATION zh
    ADD MAPPING FOR n,v,a,i,e,l WITH simple;
  • 在要做 search 的 table 加入 tsvector 欄位
    1
    ALTER TABLE documents ADD COLUMN fts tsvector;
  • 更新中英文混合的 tsvector 欄位內容
    1
    2
    3
    4
    5
    6
    7
    8
    9
    UPDATE documents
    SET fts =
    setweight(to_tsvector('zh', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('zh', coalesce(content, '')), 'B') ||
    setweight(to_tsvector('english', coalesce(content, '')), 'B') ||
    -- simple:保險用(防繁中切不好)
    setweight(to_tsvector('simple', coalesce(title, '')), 'C') ||
    setweight(to_tsvector('simple', coalesce(content, '')), 'D');
    • 把 documents 裡的 title + content 分別用中文跟英文斷詞,設定不同權重,合併成 tsvector 存進 fts 這個欄位
    • coalesce(title, '') 表示如果 title 是 NULL 就當成空字串,避免 to_tsvector(NULL) 直接變成 NULL
    • setweight(to_tsvector('zh', coalesce(title, '')), 'A')zh config 切欄位 title 的中文、產生 tsvector、權重設為 A(最高)
    • setweight(to_tsvector('zh', coalesce(content, '')), 'B') 跟上面差在權重是 B(比 A 低)
    • setweight(to_tsvector('english', coalesce(title, '')), 'A') 就是切英文
    • || 是用來合併 tsvector 的
    • 權重會影響搜尋 SQL ORDER BY ts_rank(fts, q) DESC ,權重高的結果會自動排前面
  • 建立 GIN index
    1
    2
    3
    CREATE INDEX documents_fts_idx
    ON documents
    USING GIN (fts);
    • 建個 index 加快搜尋,不然搜尋會慢到死
    • GIN = Generalized Inverted Index
      • 反向索引
      • 不是存一列有什麼文字,而是存「每個詞出現在哪些 rows」
  • 搜尋 query
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    SELECT uuid, title,
    ts_headline(
    'zh',
    content,
    plainto_tsquery('zh', 'Docker 中文搜尋')
    || plainto_tsquery('english', 'Docker 中文搜尋'),
    'MaxWords=100, MinWords=50'
    ) AS snippet
    FROM documents
    WHERE fts @@ (
    plainto_tsquery('zh', 'Docker 中文搜尋')
    || plainto_tsquery('english', 'Docker 中文搜尋')
    )
    ORDER BY ts_rank(
    fts,
    plainto_tsquery('zh', 'Docker 中文搜尋')
    || plainto_tsquery('english', 'Docker 中文搜尋')
    ) DESC
    LIMIT 20
    OFFSET 10;
    • ts_headline 會從 content 內擷取「命中關鍵字附近」的一小段文字
    • fts @@ ( ... ) 是全文搜尋的比對 operator,意思是「這筆文件的索引內容是否符合搜尋條件?」
    • plainto_tsquery('zh', 'Docker 中文搜尋')zh config 把輸入轉成 tsquery
      • 會自動處理空白
    • plainto_tsquery('english', 'Docker 中文搜尋') 同一段輸入用英文規則再解析一次
    • 兩個 plainto_tsquery|| 連接表示 OR
    • ts_rank(fts, tsquery) 是 PostgreSQL 算「相關度分數」,會考量命中幾次、權重跟詞出現的位置。
      • ORDER BY … DESC 排序就能讓相關度高的結果排前面。
      • tsquery 在這裡要再寫一次,因為 WHERE 跟 ORDER BY 是同一層,不能 reuse expression

Ref